Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27db0d3c67 | |||
| 9e30b72b27 | |||
| 7b12d907cc | |||
| d1d592d793 | |||
| 3dfb859676 |
@@ -1,406 +0,0 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# Release Discord Announcement Skill
|
||||
|
||||
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
|
||||
a single copy-pasteable Discord block, in dotta's voice, and posts it as the
|
||||
`discord_announcement` document on the release issue.
|
||||
|
||||
## What dotta said
|
||||
|
||||
> This is for discord — try to follow my format. If I have a section where I
|
||||
> think about the future, pull from recent issues we're working on etc.
|
||||
|
||||
The Discord announcement is **not** the changelog. The changelog is exhaustive;
|
||||
the announcement is opinionated, in-voice, and built around the same handful of
|
||||
shipped highlights plus a real "what's next" + "what's on my mind" pulled from
|
||||
current Paperclip work — not invented.
|
||||
|
||||
## When to use
|
||||
|
||||
- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the
|
||||
release worktree/PR.
|
||||
- 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.
|
||||
- Never run this in isolation. The version, date, contributor list, and
|
||||
highlight set MUST match the matching changelog file — if the changelog has
|
||||
been updated, refresh this too.
|
||||
|
||||
## Output
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
PUT /api/issues/{releaseIssueId}/documents/discord_announcement
|
||||
{
|
||||
"title": "Discord announcement",
|
||||
"format": "markdown",
|
||||
"body": "<the announcement>",
|
||||
"baseRevisionId": "<latest if updating>"
|
||||
}
|
||||
```
|
||||
|
||||
If the document already exists, fetch it first and pass the current
|
||||
`baseRevisionId`. Never overwrite silently — if the version has changed since
|
||||
the document was last written, mention what changed in the issue comment.
|
||||
|
||||
## Format (follow this template)
|
||||
|
||||
Use Discord emoji shortcodes (`:paperclip:`, `:lock:`, `:brain:` …) — NOT the
|
||||
Unicode emoji. Discord renders the shortcodes; the changelog file uses prose.
|
||||
|
||||
```
|
||||
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v{VERSION} IS OUT :paperclip: :paperclip: :paperclip:
|
||||
|
||||
OFFICIAL TWITTER: https://x.com/papercliping - follow it, report any others
|
||||
|
||||
## Highlights
|
||||
|
||||
:emoji: **Feature Name** - one-sentence description in dotta's voice.
|
||||
:emoji: **Feature Name** - …
|
||||
:emoji: **Feature Name** - …
|
||||
|
||||
... and a long tail of {flavor of the rest}. Read the [full release notes](<github link>).
|
||||
|
||||
## WHATS NEXT (:motorway: Roadmap)
|
||||
|
||||
* **Theme A** - one-line forward-looking blurb
|
||||
* **Theme B** - …
|
||||
* **Theme C** - …
|
||||
|
||||
## What's on my mind
|
||||
|
||||
* **Topic** - what's bugging dotta / what's queued / open questions
|
||||
* **Topic** - …
|
||||
|
||||
## PRESS (optional — only if there is real press)
|
||||
|
||||
* **Outlet / Person** - what happened ([link](<x.com link>))
|
||||
|
||||
## WHAT I NEED FROM YOU (optional — only if there's a real ask)
|
||||
|
||||
FOLLOW THE TWITTER: https://x.com/papercliping - that's the only official one
|
||||
TELL ME if you're using Paperclip in your business - I want to meet you
|
||||
|
||||
## Community
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
```
|
||||
@username1, @username2, @username3
|
||||
```
|
||||
|
||||
## In Summary
|
||||
|
||||
PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK
|
||||
|
||||
Every single person will be managing a team of a dozen, or a hundred, or a
|
||||
thousand agents and Paperclip will be the default tool to manage it all.
|
||||
|
||||
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
|
||||
|
||||
FULL RELEASE NOTES
|
||||
|
||||
https://github.com/paperclipai/paperclip/blob/master/releases/v{VERSION}.md
|
||||
|
||||
||@everyone||
|
||||
```
|
||||
|
||||
Notes on the template:
|
||||
|
||||
- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are
|
||||
part of the brand — keep them.
|
||||
- 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
|
||||
exactly once when the spoiler is removed by the poster.
|
||||
|
||||
## Language tips
|
||||
|
||||
These are extracted from how dotta has written the last several announcements.
|
||||
Mimic this register; do not invent a "professional" tone.
|
||||
|
||||
- **First person, conversational.** "I want to meet companies using Paperclip",
|
||||
"what's on my mind", "if that's you let me know". Not "Paperclip is excited
|
||||
to announce".
|
||||
- **ALL CAPS for excitement and asks**, especially in the opener, the section
|
||||
headers, the "WHAT I NEED FROM YOU" section, and the closing tagline. Do not
|
||||
ALL-CAPS feature descriptions.
|
||||
- **One emoji shortcode per highlight bullet**, picked to evoke the feature
|
||||
(`:lock:` for secrets, `:brain:` for planning, `:mag:` for search,
|
||||
`:cloud:` for cloud / sandbox, `:jigsaw:` for plugins, `:rewind:` for
|
||||
history/restore, `:thread:` for threads, etc.).
|
||||
- **Highlight bullets are one sentence**, opinionated, told from the user's
|
||||
perspective — "the cloud-secrets prereq is real now", not "added support
|
||||
for…".
|
||||
- **Tail line after highlights** wraps the rest in a single sentence and links
|
||||
to the full release notes ("… and a long tail of {flavor}. Read the [full
|
||||
release notes](url).").
|
||||
- **"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.
|
||||
- **"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,
|
||||
hit me up"). Pull real tensions from recent issues/comments; do not invent.
|
||||
- **Press section** is optional. Only include it if there is real press in the
|
||||
release window (a tweet, a podcast, a talk, a star milestone). No press →
|
||||
drop the section entirely.
|
||||
- **"WHAT I NEED FROM YOU"** is optional. Use it for a single concrete ask
|
||||
(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.
|
||||
- **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"
|
||||
- "PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY"
|
||||
- "Paperclip will be _the_ control plane for AI agents in **every** company."
|
||||
- **Closing tagline** is always `ITS TIME TO CLIP :paperclip: :paperclip:
|
||||
:paperclip:`. Keep it.
|
||||
|
||||
## 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
|
||||
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
|
||||
"Output" above). If updating, send the latest `baseRevisionId`.
|
||||
6. 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.
|
||||
|
||||
Do not publish to Discord. This skill only prepares the artifact.
|
||||
|
||||
## Verbatim previous examples
|
||||
|
||||
Three previous Discord announcements from dotta, included **verbatim** as the
|
||||
ground-truth examples for voice, structure, and emoji usage. When in doubt,
|
||||
match these.
|
||||
|
||||
### Example 1 — v2026.403.0
|
||||
|
||||
```
|
||||
CLIPPERS! v2026.403.0 has dropped!! :paperclip: :paperclip: :paperclip:
|
||||
|
||||
## Highlights
|
||||
|
||||
:inbox_tray: **Inbox overhaul** - there is a new "mine" tab that has mail-client like keyboard shortcuts. It's my new default view for managing work
|
||||
:thumbsup: **Feedback and evals** - you can now vote :thumbsup: / :thumbsdown: on your agent's responses. If you choose to share your traces with me, I'll use it to make Paperclip better. In either case you can export locally for your own org's learning
|
||||
:page_with_curl: **Document revisions** - you can now restore old versions of your documents
|
||||
:ping_pong: **Telemetry** - this version has anonymized telemetry that helps me better understand the basic uses of Paperclip (adapters and so on) - if you hate that, just it disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` environment variables
|
||||
:construction_worker: **Execution Workspaces (experimental)** - Paperclip is not a "code review" tool, but I have been finding worktrees are important for certain projects. Enable it in experimental settings
|
||||
:loop: **Routine variables** - sometimes you need to customize a routine and the new variables feature makes that easy
|
||||
|
||||
PLUS **tons** of improvements aound adapters, bugfixes, qol
|
||||
|
||||
## COMMUNITY
|
||||
|
||||
HUGE THANKS to the contributors with commits in this release:
|
||||
|
||||
```
|
||||
@aronprins, @bittoby, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai
|
||||
```
|
||||
|
||||
## WHATS NEXT (ROADMAP)
|
||||
|
||||
* **Multi-human users** -- you've been asking for it, we have a draft and will have this shortly
|
||||
* **Sandbox execution** - the other half of cloud deployment: run your agents in a sandbox across any provider
|
||||
|
||||
PLUS: just dealing with the excellent PRs we have sitting in our inbox.
|
||||
|
||||
**What's also on my mind (coming soonish)**
|
||||
|
||||
* MAXIMIZER MODE - for when you've got a dream and tokens to burn
|
||||
* Artifacts, work products, and deployments
|
||||
* CEO Chat
|
||||
* Stronger agent defaults
|
||||
|
||||
## PRESS
|
||||
|
||||
I've been doing my part to spread the word about Paperclip
|
||||
|
||||
* We talked to the incredible [Andrew Warner of Mixergy Fame](https://x.com/dotta/status/2039087507514507407)
|
||||
* We gave a tutorial with the [inimitable Greg Isenberg](https://x.com/dotta/status/2037279902445994345)
|
||||
* We met with the [Seed Club guys](https://x.com/dotta/status/2039020365926576377)
|
||||
* We crossed [40k stars (46k now!)](https://x.com/dotta/status/2038638188227387613)
|
||||
* ... and a couple others that will be released in a few days
|
||||
|
||||
## SUCCESS STORIES
|
||||
|
||||
* [Nevo made $76k in march](https://x.com/dotta/status/2039406772859920758) after using Paperclip to automate his marketing
|
||||
* [Lewis Jackson](https://x.com/WhatSayLew/status/2039810227394978158) said 34 agents were already operating his trading firm through Paperclip and called it his "holy s***" AI moment.
|
||||
* [Neal Kotak](https://x.com/nkotak1/status/2039582439459209638) said Paperclip already runs most of Roominary for him and praised how strong the product is.
|
||||
* [Sam Woods](https://x.com/samwoods/status/2039039305960587755) said he knows several people who moved from OpenClaw to Paperclip, often with Hermes in the stack, and that they love it.
|
||||
* [Josh Galt](https://x.com/JoshGalt/status/2039386307219095557) called Paperclip the coolest agent tooling he has used and said it is finally something that just works.
|
||||
|
||||
## IN SUMMARY
|
||||
|
||||
I know there are still some rough edges, but
|
||||
|
||||
Paperclip will be *the* control plane for AI agents in **every** company.
|
||||
|
||||
and I think we're moving at a pretty good clip :paperclip: :paperclip: :paperclip:
|
||||
|
||||
FULL RELEASE NOTES HERE
|
||||
|
||||
https://github.com/paperclipai/paperclip/releases/tag/v2026.403.0
|
||||
|
||||
||@everyone||
|
||||
```
|
||||
|
||||
### Example 2 — v2026.416.0
|
||||
|
||||
```
|
||||
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.416.0 IS OUT :paperclip: :paperclip: :paperclip:
|
||||
|
||||
## Highlights
|
||||
|
||||
This release has *tons* of quality of life improvements around speed, performance, and workflow. You should notice that comment threads feel faster and your agents stay on task longer
|
||||
|
||||
:thread: Issue chat threads now are a conversation more than comments
|
||||
:police_officer: Execution policies like **Reviewer** and **Approver** are now first-class in the harness (e.g. enforce that QA *must* review a task)
|
||||
:no_smoking: Blocker dependencies - first-class "wake on blocker resolved" which means now you can have "task graphs" that depend on one another and it's enforced by Paperclip
|
||||
:woman_feeding_baby: Parent-child tasks - better support for sub-tasks all around, which makes it much easier to organize your work
|
||||
|
||||
And then a million fixes around ux, details, keyboard shortcuts, bug fixes, security fixes, etc. Really you should read the [full release notes here](https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0)
|
||||
|
||||
## COMMUNITY
|
||||
|
||||
INCREDIBLE INCREDIBLE WORK BY folks with commits and reports in this release:
|
||||
|
||||
```
|
||||
@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt, @offset, @sagilayani, @mattdonnelly10, @peaktwilight, @YuvalElbar6
|
||||
```
|
||||
|
||||
## WHATS NEXT (:motorway: Roadmap)
|
||||
|
||||
* **Multi-human users** - in the last stages of testing, Paperclip is better with teams
|
||||
* **Memory Infrastructure** - your agents will remember everything about yoru business
|
||||
* **Sandbox execution** - run your agents anywhere
|
||||
|
||||
## What's on my mind
|
||||
|
||||
* I want to meet with companies who are using Paperclip in their business - if that's you let me know
|
||||
* We need more Paperclip tutorials, defaults, and education - thanks to @aronprins for his work in this area already!
|
||||
* We still need to get better at reviewing your PRs and we're improving our process every day
|
||||
* "Zero-human company" language has to go - we're the human control plane for ai labor
|
||||
* We're adding better support for *knowledge (wikis & files)*, *artifacts*, and *work product* in Paperclip soon.
|
||||
|
||||
## PRESS
|
||||
|
||||
* **AI Engineer Europe Tutorial** - I gave a tutorial for AIE. If someone is looking for a basics ABC of Paperclip [you can send them this](https://x.com/dotta/status/2044575580264316931)
|
||||
* **AI Club Chicago** - JB gave a talk on Paperclip [at AI Tinkerers in Chicago](https://x.com/developwithJB/status/2044281068778316268) !
|
||||
|
||||
## IN SUMMARY
|
||||
|
||||
PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY
|
||||
|
||||
If there's anything I can do to help you and your company use Paperclip, hit me up. Until then, enjoy the new release
|
||||
|
||||
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
|
||||
|
||||
FULL RELEASE NOTES
|
||||
|
||||
https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0
|
||||
|
||||
||@everyone||
|
||||
```
|
||||
|
||||
### Example 3 — v2026.427.0
|
||||
|
||||
```
|
||||
:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.427.0 IS OUT :paperclip: :paperclip: :paperclip:
|
||||
|
||||
THIS IS THE OFFICIAL TWITTER FOLLOW IT: https://x.com/papercliping
|
||||
|
||||
## Highlights
|
||||
|
||||
:man_feeding_baby: **MULTI USER** - you can now invite multiple users to your instance
|
||||
:factory_worker: **HARDER WORKING** - robosut liveness continuations and lifecycle recovery means your instance tries harder before involving you
|
||||
:white_check_mark: **SUBISSUE CHECKLISTS** - subissues have better ordering which allows for long-run planning
|
||||
:thread: **Rich Thread UX** - now your agents can ask you questions, ask for approvals, suggest tasks and you can approve or refine them right in your task threads
|
||||
:cloud: **BETA: Sandbox Providers** - Cloud sandboxing is in beta - the API ships in this release and we'll be adding more providers
|
||||
... and *tons* of other improvements and bugfixes.
|
||||
|
||||
## Community
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
```
|
||||
@akhater, @aronprins, @GodsBoy, @LeonSGP43, @neerazz, @NoronhaH, @rbarinov, @rvanduiven, @SgtPooki, @superbiche
|
||||
```
|
||||
|
||||
## WHATS NEXT (:motorway: Roadmap)
|
||||
|
||||
* **Longer-range planning and execution** - Paperclip will support longer and longer tasks and work until it's done
|
||||
* **Secrets Service v2** - an important prereq for Paperclip cloud
|
||||
* **Artifacts, memory, and knowledge**
|
||||
* **Conference Room** aka CEO/Agent Chat
|
||||
|
||||
## What's on my mind
|
||||
|
||||
* **Documentation & Blog posts** - I've fallen behind on the docs but aron has done a good job here - we'll be setting up Clips to help maintain these
|
||||
* **Paperclip Cloud** - will be a critical unlock for us, but even the shared team story needs developed more - *where should the work be done* and *where are the outputs stored* and *how do we surface them to users*? Each of these questions are a core Paperclip service that needs developed
|
||||
* **Paperclip Bench** - In the vein of SWE-Bench I've started an internal benchmark for Paperclip - we have to be able to measure that our changes are improving the system and not regressing
|
||||
* **Paperclip Connections Store** - connecting to Github, Slack, Google Docs, and the hundreds of other services we use every day should be easy, secure, and configurable per agent and team
|
||||
|
||||
## Press
|
||||
|
||||
I met with the [Wisemen about Paperclip](https://x.com/dotta/status/2045146539534827998)
|
||||
|
||||
## WHAT I NEED FROM YOU
|
||||
|
||||
FOLLOW THIS TWITTER ACCOUNT: https://x.com/papercliping - that's the only official one, report any others
|
||||
|
||||
## In Summary
|
||||
|
||||
PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK
|
||||
|
||||
Every single person will be managing a team of a dozen, or a hundred, or a thousand agents and Paperclip will be the default tool to manage it all.
|
||||
|
||||
ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:
|
||||
|
||||
FULL RELEASE NOTES
|
||||
|
||||
https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md
|
||||
|
||||
||@everyone||
|
||||
```
|
||||
|
||||
## Review checklist
|
||||
|
||||
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).
|
||||
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 /
|
||||
active goals — not invented themes.
|
||||
5. Section style (UPPERCASE vs Title Case) is internally consistent.
|
||||
6. Closing tagline is `ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:`
|
||||
and `||@everyone||` is the very last line.
|
||||
7. Document `discord_announcement` is updated on the release issue, and the
|
||||
announcement is also posted in a comment inside a fenced code block.
|
||||
|
||||
This skill never posts to Discord. It only prepares the announcement artifact.
|
||||
@@ -1,77 +0,0 @@
|
||||
name: "Build: Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
image-tag: ${{ steps.tag.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image tag
|
||||
id: tag
|
||||
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: admin
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.farh.net/farhoodlabs/paperclip-dev
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
|
||||
update-infra:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update dev image tag in infra repo
|
||||
run: |
|
||||
SHA="${{ needs.build.outputs.image-tag }}"
|
||||
FILE="overlays/dev/kustomization.yaml"
|
||||
|
||||
response=$(curl -sS \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE")
|
||||
|
||||
file_sha=$(echo "$response" | jq -r '.sha')
|
||||
content=$(echo "$response" | jq -r '.content' | base64 -d)
|
||||
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
|
||||
encoded=$(printf '%s' "$new_content" | base64 -w 0)
|
||||
|
||||
curl -sS -X PUT \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
||||
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
||||
@@ -1,48 +0,0 @@
|
||||
name: "Build: Production"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [local]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: admin
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.farh.net/farhoodlabs/paperclip
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -29,11 +29,9 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: google-chrome --version
|
||||
- run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run e2e tests
|
||||
env:
|
||||
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -23,9 +23,7 @@ jobs:
|
||||
- name: Block manual lockfile edits
|
||||
if: github.head_ref != 'chore/refresh-lockfile'
|
||||
run: |
|
||||
# Diff the PR branch against its merge base so recent base-branch commits
|
||||
# do not masquerade as changes made by the PR itself.
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
||||
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
||||
exit 1
|
||||
@@ -45,117 +43,15 @@ jobs:
|
||||
- name: Validate Dockerfile deps stage
|
||||
run: node ./scripts/check-docker-deps-stage.mjs
|
||||
|
||||
- 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: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Verify release package bootstrap for changed manifests
|
||||
run: |
|
||||
mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")
|
||||
PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \
|
||||
node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}"
|
||||
|
||||
- name: Validate dependency resolution when manifests change
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
|
||||
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)$'
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
fi
|
||||
|
||||
typecheck_release_registry:
|
||||
name: Typecheck + Release Registry
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck workspaces whose build scripts skip TypeScript
|
||||
run: pnpm run typecheck:build-gaps
|
||||
|
||||
- name: Verify release registry test coverage
|
||||
run: pnpm run test:release-registry
|
||||
|
||||
general_tests:
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: general-server
|
||||
group_label: server
|
||||
- group: general-workspaces-a
|
||||
group_label: workspaces-a
|
||||
- group: general-workspaces-b
|
||||
group_label: workspaces-b
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run grouped general test suites
|
||||
run: pnpm test:run:general -- --group '${{ matrix.group }}'
|
||||
|
||||
verify:
|
||||
# Preserve the legacy required-check name while the underlying work runs in parallel.
|
||||
name: verify
|
||||
if: ${{ always() }}
|
||||
needs: [typecheck_release_registry, general_tests, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Fail if any split verify lane failed
|
||||
env:
|
||||
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
|
||||
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
|
||||
BUILD_RESULT: ${{ needs.build.result }}
|
||||
run: |
|
||||
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
|
||||
test "$GENERAL_TESTS_RESULT" = "success"
|
||||
test "$BUILD_RESULT" = "success"
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -178,79 +74,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
verify_serialized_server:
|
||||
name: Verify serialized server suites (${{ matrix.shard_label }})
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard_index: 0
|
||||
shard_count: 4
|
||||
shard_label: 1/4
|
||||
- shard_index: 1
|
||||
shard_count: 4
|
||||
shard_label: 2/4
|
||||
- shard_index: 2
|
||||
shard_count: 4
|
||||
shard_label: 3/4
|
||||
- shard_index: 3
|
||||
shard_count: 4
|
||||
shard_label: 4/4
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run serialized server test shard
|
||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
||||
|
||||
canary_dry_run:
|
||||
name: Canary Dry Run
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# `release.sh` always executes its Step 2/7 workspace build, even when
|
||||
# `--skip-verify` bypasses the initial verification gate.
|
||||
- name: Release canary dry run via release.sh internal build
|
||||
- name: Release canary dry run
|
||||
run: |
|
||||
git checkout -B master HEAD
|
||||
git checkout -- pnpm-lock.yaml
|
||||
@@ -279,11 +112,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify runner Chrome
|
||||
# GitHub's Ubuntu runner image already ships Google Chrome, so use that
|
||||
# directly for the headless e2e lane instead of downloading Playwright
|
||||
# browser bundles inside the 30 minute job budget.
|
||||
run: google-chrome --version
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Generate Paperclip config
|
||||
run: |
|
||||
@@ -303,7 +136,6 @@ jobs:
|
||||
- name: Run e2e tests
|
||||
env:
|
||||
PAPERCLIP_E2E_SKIP_LLM: "true"
|
||||
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
|
||||
@@ -58,10 +58,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Verify runner Chrome
|
||||
# Release smoke also runs headless on GitHub's Ubuntu image, so use the
|
||||
# runner's preinstalled Chrome instead of a Playwright browser download.
|
||||
run: google-chrome --version
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Launch Docker smoke harness
|
||||
run: |
|
||||
@@ -91,7 +89,6 @@ jobs:
|
||||
PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }}
|
||||
PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }}
|
||||
PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }}
|
||||
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
|
||||
run: pnpm run test:release-smoke
|
||||
|
||||
- name: Capture Docker logs
|
||||
|
||||
@@ -50,9 +50,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -92,9 +89,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -145,9 +139,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -186,9 +177,6 @@ jobs:
|
||||
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
|
||||
|
||||
|
||||
@@ -55,6 +55,4 @@ tests/e2e/playwright-report/
|
||||
tests/release-smoke/test-results/
|
||||
tests/release-smoke/playwright-report/
|
||||
.superset/
|
||||
.superpowers/
|
||||
.claude/worktrees/
|
||||
.herenow
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Paperclip fork — farhoodlabs
|
||||
|
||||
This is a thin fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
||||
Fork repo: https://git.farh.net/farhoodlabs/paperclip
|
||||
|
||||
## Branch model
|
||||
|
||||
| Branch | Purpose |
|
||||
|---|---|
|
||||
| `master` | Pure mirror of `upstream/master`. No fork files. Sync via `git push origin upstream/master:master --force-with-lease`. |
|
||||
| `dev` | `master` + one fork commit (Dockerfile prod stage + 2 build workflows). Builds `git.farh.net/farhoodlabs/paperclip-dev:*` on push. |
|
||||
| `local` | **Deployed branch.** Same content as `dev`. Builds `git.farh.net/farhoodlabs/paperclip:*` on push. |
|
||||
|
||||
The fork tree differs from upstream by exactly **3 files**:
|
||||
|
||||
```
|
||||
Dockerfile (production stage adds kubectl, kubeseal, uv, forgejo CLIs, tea, mmx-cli, nano, vim)
|
||||
.github/workflows/build-prod.yml (pushes to git.farh.net/farhoodlabs/paperclip)
|
||||
.github/workflows/build-dev.yml (pushes to git.farh.net/farhoodlabs/paperclip-dev)
|
||||
```
|
||||
|
||||
The base/deps/build stages of the Dockerfile match upstream verbatim so upstream changes apply cleanly.
|
||||
|
||||
## Sync upstream
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git push origin upstream/master:master --force-with-lease
|
||||
git checkout dev && git merge master && git push origin dev
|
||||
git checkout local && git merge dev && git push origin local
|
||||
```
|
||||
|
||||
Conflicts should only ever appear on `Dockerfile` itself (if upstream changes the production stage). Resolution rule: keep upstream's deps/base/build stages exactly; preserve the fork's `RUN` block in the production stage.
|
||||
|
||||
## Deployment
|
||||
|
||||
Production runs in Kubernetes (`paperclip` namespace, single replica). Image: `git.farh.net/farhoodlabs/paperclip:<tag>`. Flux does not watch moving tags — rolling a fix means either pushing a semver-tagged release or `kubectl rollout restart deploy/paperclip -n paperclip`.
|
||||
|
||||
## Don't
|
||||
|
||||
- **Don't add fork code changes.** This fork is intentionally minimal after the 2026-05-31 reset (event-loop starvation bug from accumulated drift). If a feature is missing relative to a prior fork iteration (Gitea-hosted skills, PAT support for private skill repos, secret export/import, k8s sandbox-provider plugin, agentId threading), surface the regression — don't pull it back from `git log` without explicit go-ahead.
|
||||
- **Don't commit to `local` without going through `dev` first** (and through `master` for upstream syncs). The promotion order is enforced.
|
||||
- **Don't recreate `.farhoodlabs/` overlay or `assemble-local.yml`.** That model was retired.
|
||||
@@ -22,24 +22,17 @@ 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/mcp-server/package.json packages/mcp-server/
|
||||
COPY packages/skills-catalog/package.json packages/skills-catalog/
|
||||
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
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/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
|
||||
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
|
||||
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
|
||||
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
|
||||
COPY patches/ patches/
|
||||
COPY scripts/link-plugin-dev-sdk.mjs scripts/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -57,29 +50,10 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools, mmx-cli
|
||||
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client jq \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
|
||||
&& chmod +x /usr/local/bin/kubectl \
|
||||
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
|
||||
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
|
||||
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& mv /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& curl -fsSL https://codeberg.org/forgejo-contrib/forgejo-cli/releases/download/v0.4.1/forgejo-cli-linux.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/fj \
|
||||
&& curl -fsSL https://github.com/JKamsker/forgejo-cli-ex/releases/download/v0.1.7/fj-ex-linux-x86_64.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/fj-ex \
|
||||
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
|
||||
&& chmod +x /usr/local/bin/fgj \
|
||||
&& curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \
|
||||
&& chmod +x /usr/local/bin/tea \
|
||||
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& npm install --global --omit=dev mmx-cli \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="doc/assets/banner.jpg" alt="Paperclip is the app people use to manage AI agents for work." width="720" />
|
||||
<img src="doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -7,8 +7,7 @@
|
||||
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
||||
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> ·
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> ·
|
||||
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> ·
|
||||
<a href="https://paperclip.ing"><strong>Website</strong></a>
|
||||
<a href="https://x.com/papercliping"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -25,15 +24,15 @@
|
||||
|
||||
<br/>
|
||||
|
||||
# Paperclip is the app people use to manage AI agents for work.
|
||||
## What is Paperclip?
|
||||
|
||||
Open-source orchestration for teams of AI agents.
|
||||
# Open-source orchestration for zero-human companies
|
||||
|
||||
**If OpenClaw is an _employee_, Paperclip is the _company_.**
|
||||
**If OpenClaw is an _employee_, Paperclip is the _company_**
|
||||
|
||||
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track work and costs from one dashboard.
|
||||
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard.
|
||||
|
||||
It looks like a task manager. Under the hood: org charts, budgets, governance, goal alignment, and agent coordination.
|
||||
It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination.
|
||||
|
||||
**Manage business goals, not pull requests.**
|
||||
|
||||
@@ -45,6 +44,10 @@ It looks like a task manager. Under the hood: org charts, budgets, governance, g
|
||||
|
||||
<br/>
|
||||
|
||||
> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds.
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
@@ -110,7 +113,7 @@ Every conversation traced. Every decision explained. Full tool-call tracing and
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>🛡️ Governance</h3>
|
||||
Approve hires, override strategy, pause or terminate any agent — at any time.
|
||||
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>📊 Org Chart</h3>
|
||||
@@ -219,7 +222,7 @@ Paperclip is a full control plane, not a wrapper. Before you build any of this y
|
||||
</td>
|
||||
<td>
|
||||
|
||||
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. Nothing ships without your sign-off.
|
||||
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. You're the board — nothing ships without your sign-off.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -314,7 +317,7 @@ This starts the API server at `http://localhost:3100`. An embedded PostgreSQL da
|
||||
**What does a typical setup look like?**
|
||||
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
|
||||
|
||||
If you're a solo entrepreneur you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
|
||||
If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
|
||||
|
||||
**Can I run multiple companies?**
|
||||
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
|
||||
@@ -415,7 +418,7 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
|
||||
|
||||
## License
|
||||
|
||||
MIT © 2026 [Paperclip Labs, Inc](https://paperclip.ing)
|
||||
MIT © 2026 Paperclip
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -426,5 +429,9 @@ MIT © 2026 [Paperclip Labs, Inc](https://paperclip.ing)
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Open source under MIT. Built for people who want to get work done, not babysit agents.</sub>
|
||||
<img src="doc/assets/footer.jpg" alt="" width="720" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Open source under MIT. Built for people who want to run companies, not babysit agents.</sub>
|
||||
</p>
|
||||
|
||||
@@ -226,21 +226,6 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
|
||||
|
||||
<br/>
|
||||
|
||||
## Paperclip Cloud Sync
|
||||
|
||||
Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing.
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -37,13 +37,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.10.0",
|
||||
"@paperclipai/adapter-acpx-local": "workspace:*",
|
||||
"@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-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
@@ -51,7 +48,7 @@
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/server": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.38.4",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -244,7 +244,6 @@ describe("renderCompanyImportPreview", () => {
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
comments: [],
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
@@ -461,7 +460,6 @@ describe("import selection catalog", () => {
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
comments: [],
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -17,14 +16,13 @@ describe("home path resolution", () => {
|
||||
});
|
||||
|
||||
it("defaults to ~/.paperclip and default instance", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
const paths = describeLocalInstancePaths();
|
||||
expect(paths.homeDir).toBe(home);
|
||||
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
|
||||
expect(paths.instanceId).toBe("default");
|
||||
expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "config.json"));
|
||||
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
|
||||
});
|
||||
|
||||
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
||||
@@ -36,7 +34,7 @@ describe("home path resolution", () => {
|
||||
});
|
||||
|
||||
it("rejects invalid instance ids", () => {
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid PAPERCLIP_INSTANCE_ID/);
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
|
||||
});
|
||||
|
||||
it("expands ~ prefixes", () => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
|
||||
import { buildPresetServerConfig } from "../config/server-bind.js";
|
||||
|
||||
const ORIGINAL_PATH = process.env.PATH;
|
||||
|
||||
describe("network bind helpers", () => {
|
||||
it("rejects non-loopback bind modes in local_trusted", () => {
|
||||
expect(
|
||||
@@ -52,18 +50,13 @@ describe("network bind helpers", () => {
|
||||
|
||||
it("falls back to loopback when no tailscale address is available for tailnet presets", () => {
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
process.env.PATH = "";
|
||||
|
||||
try {
|
||||
const preset = buildPresetServerConfig("tailnet", {
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
});
|
||||
const preset = buildPresetServerConfig("tailnet", {
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
});
|
||||
|
||||
expect(preset.server.host).toBe("127.0.0.1");
|
||||
} finally {
|
||||
process.env.PATH = ORIGINAL_PATH;
|
||||
}
|
||||
expect(preset.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ import { onboard } from "../commands/onboard.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_PATH = process.env.PATH;
|
||||
|
||||
function createExistingConfigFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
|
||||
@@ -87,18 +85,10 @@ 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_HOME;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_BIND;
|
||||
delete process.env.PAPERCLIP_BIND_HOST;
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
delete process.env.HOST;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
});
|
||||
|
||||
it("preserves an existing config when rerun without flags", async () => {
|
||||
@@ -135,27 +125,6 @@ describe("onboard", () => {
|
||||
expect(raw.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("creates instance-root config and data paths for a fresh PAPERCLIP_HOME", async () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-home-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-cwd-"));
|
||||
process.chdir(cwd);
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
|
||||
await onboard({ yes: true, invokedByRun: true });
|
||||
|
||||
const instanceRoot = path.join(home, "instances", "default");
|
||||
const configPath = path.join(instanceRoot, "config.json");
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
|
||||
expect(raw.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
||||
expect(raw.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
|
||||
expect(raw.logging.logDir).toBe(path.join(instanceRoot, "logs"));
|
||||
expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
||||
expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
||||
expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true);
|
||||
});
|
||||
|
||||
it("supports authenticated/private quickstart bind presets", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
||||
@@ -172,13 +141,8 @@ describe("onboard", () => {
|
||||
it("keeps tailnet quickstart on loopback until tailscale is available", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
process.env.PATH = "";
|
||||
|
||||
try {
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||
} finally {
|
||||
process.env.PATH = ORIGINAL_PATH;
|
||||
}
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
expect(raw.server.deploymentMode).toBe("authenticated");
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
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";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/plugins/create-paperclip-plugin/src/index.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../../packages/plugins/create-paperclip-plugin/src/index.js")>(
|
||||
"../../../packages/plugins/create-paperclip-plugin/src/index.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
scaffoldPluginProject: mocks.scaffoldPluginProject,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildPluginInstallRequest,
|
||||
buildPluginInitNextCommands,
|
||||
buildPluginInitScaffoldOptions,
|
||||
registerPluginCommands,
|
||||
} from "../commands/client/plugin.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin init", () => {
|
||||
beforeEach(() => {
|
||||
mocks.scaffoldPluginProject.mockClear();
|
||||
});
|
||||
|
||||
it("maps package name and flags to scaffolder options", () => {
|
||||
const cwd = path.resolve("/tmp/paperclip-cli-test");
|
||||
const options = buildPluginInitScaffoldOptions(
|
||||
"@acme/plugin-linear",
|
||||
{
|
||||
output: "plugins",
|
||||
template: "connector",
|
||||
category: "automation",
|
||||
displayName: "Linear Bridge",
|
||||
description: "Syncs Linear issues",
|
||||
author: "Acme",
|
||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
||||
},
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(options).toEqual({
|
||||
pluginName: "@acme/plugin-linear",
|
||||
outputDir: path.resolve(cwd, "plugins", "plugin-linear"),
|
||||
template: "connector",
|
||||
category: "automation",
|
||||
displayName: "Linear Bridge",
|
||||
description: "Syncs Linear issues",
|
||||
author: "Acme",
|
||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds exact next commands using the scaffold path", () => {
|
||||
expect(buildPluginInitNextCommands("/tmp/acme plugin")).toEqual([
|
||||
"cd '/tmp/acme plugin'",
|
||||
"pnpm install",
|
||||
"pnpm dev",
|
||||
"paperclipai plugin install '/tmp/acme plugin'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers the CLI wrapper and invokes the existing scaffolder", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerPluginCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"plugin",
|
||||
"init",
|
||||
"demo-plugin",
|
||||
"--output",
|
||||
"/tmp/paperclip-init-output",
|
||||
"--template",
|
||||
"workspace",
|
||||
"--category",
|
||||
"workspace",
|
||||
"--display-name",
|
||||
"Demo Plugin",
|
||||
"--description",
|
||||
"Demo description",
|
||||
"--author",
|
||||
"Paperclip",
|
||||
"--sdk-path",
|
||||
"/repo/packages/plugins/sdk",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledWith({
|
||||
pluginName: "demo-plugin",
|
||||
outputDir: path.resolve("/tmp/paperclip-init-output", "demo-plugin"),
|
||||
template: "workspace",
|
||||
category: "workspace",
|
||||
displayName: "Demo Plugin",
|
||||
description: "Demo description",
|
||||
author: "Paperclip",
|
||||
sdkPath: "/repo/packages/plugins/sdk",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin install", () => {
|
||||
it("resolves an existing relative local path to an absolute local install request", () => {
|
||||
const cwd = makeTempDir();
|
||||
const pluginDir = path.join(cwd, "demo-plugin");
|
||||
fs.mkdirSync(pluginDir);
|
||||
|
||||
expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({
|
||||
packageName: pluginDir,
|
||||
version: undefined,
|
||||
isLocalPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an absolute local path absolute and marks it as local", () => {
|
||||
const pluginDir = path.join(makeTempDir(), "demo-plugin");
|
||||
fs.mkdirSync(pluginDir);
|
||||
|
||||
expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({
|
||||
packageName: pluginDir,
|
||||
version: undefined,
|
||||
isLocalPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves npm package installs when no local path exists", () => {
|
||||
expect(
|
||||
buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, {
|
||||
cwd: makeTempDir(),
|
||||
}),
|
||||
).toEqual({
|
||||
packageName: "@acme/plugin-linear",
|
||||
version: "1.2.3",
|
||||
isLocalPath: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Agent, CompanySecret } from "@paperclipai/shared";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { secretsCheck } from "../checks/secrets-check.js";
|
||||
import {
|
||||
buildInlineMigrationSecretName,
|
||||
buildMigratedAgentEnv,
|
||||
collectInlineSecretMigrationCandidates,
|
||||
parseSecretsInclude,
|
||||
toPlainEnvValue,
|
||||
} from "../commands/client/secrets.js";
|
||||
|
||||
function agent(partial: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: "agent-12345678",
|
||||
companyId: "company-1",
|
||||
name: "Coder",
|
||||
urlKey: "coder",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: {
|
||||
canCreateAgents: false,
|
||||
},
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function secret(partial: Partial<CompanySecret>): CompanySecret {
|
||||
return {
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
key: "agent_agent-12_anthropic_api_key",
|
||||
name: "agent_agent-12_anthropic_api_key",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function configWithSecretsProvider(provider: PaperclipConfig["secrets"]["provider"]): PaperclipConfig {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-05-02T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/paperclip/db",
|
||||
embeddedPostgresPort: 55432,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/paperclip/backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/tmp/paperclip/logs",
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "/tmp/paperclip/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider,
|
||||
strictMode: true,
|
||||
localEncrypted: {
|
||||
keyFilePath: "/tmp/paperclip/secrets/master.key",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("secrets CLI helpers", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("parses declaration include filters", () => {
|
||||
expect(parseSecretsInclude("agents,projects,tasks")).toEqual({
|
||||
company: false,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects inline sensitive env values that need migration", () => {
|
||||
const rows = collectInlineSecretMigrationCandidates(
|
||||
[
|
||||
agent({
|
||||
id: "agent-12345678",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||
GH_TOKEN: {
|
||||
type: "plain",
|
||||
value: "ghp-test",
|
||||
},
|
||||
PATH: {
|
||||
type: "plain",
|
||||
value: "/usr/bin",
|
||||
},
|
||||
OPENAI_API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "secret-existing",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
secret({
|
||||
id: "secret-gh-token",
|
||||
name: buildInlineMigrationSecretName("agent-12345678", "GH_TOKEN"),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
agentId: "agent-12345678",
|
||||
agentName: "Coder",
|
||||
envKey: "ANTHROPIC_API_KEY",
|
||||
secretName: "agent_agent-12_anthropic_api_key",
|
||||
existingSecretId: null,
|
||||
},
|
||||
{
|
||||
agentId: "agent-12345678",
|
||||
agentName: "Coder",
|
||||
envKey: "GH_TOKEN",
|
||||
secretName: "agent_agent-12_gh_token",
|
||||
existingSecretId: "secret-gh-token",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds migrated env bindings without preserving secret values", () => {
|
||||
const next = buildMigratedAgentEnv(
|
||||
{
|
||||
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||
NODE_ENV: {
|
||||
type: "plain",
|
||||
value: "development",
|
||||
},
|
||||
},
|
||||
new Map([["ANTHROPIC_API_KEY", "secret-1"]]),
|
||||
);
|
||||
|
||||
expect(next).toEqual({
|
||||
ANTHROPIC_API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "secret-1",
|
||||
version: "latest",
|
||||
},
|
||||
NODE_ENV: {
|
||||
type: "plain",
|
||||
value: "development",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(next)).not.toContain("sk-ant-test");
|
||||
});
|
||||
|
||||
it("reads only explicit plain env values", () => {
|
||||
expect(toPlainEnvValue("plain-value")).toBe("plain-value");
|
||||
expect(toPlainEnvValue({ type: "plain", value: "wrapped" })).toBe("wrapped");
|
||||
expect(toPlainEnvValue({ type: "secret_ref", secretId: "secret-1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the AWS bootstrap config required by doctor", () => {
|
||||
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.message).toContain("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
||||
expect(result.repairHint).toContain("AWS SDK default credential chain");
|
||||
expect(result.repairHint).toContain("Do not store AWS root credentials");
|
||||
});
|
||||
|
||||
it("passes AWS doctor checks when non-secret provider config is present", () => {
|
||||
process.env.PAPERCLIP_SECRETS_AWS_REGION = "us-east-1";
|
||||
process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID = "prod-us-1";
|
||||
process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID =
|
||||
"arn:aws:kms:us-east-1:123456789012:key/test";
|
||||
process.env.AWS_PROFILE = "paperclip-prod";
|
||||
|
||||
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.message).toContain("prod-us-1");
|
||||
expect(result.message).toContain("AWS_PROFILE/shared config");
|
||||
});
|
||||
});
|
||||
@@ -1,506 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerSkillsCommands } from "../commands/client/skills.js";
|
||||
import { resolveCompanySkillReference } from "../commands/client/skills.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function makeProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => undefined,
|
||||
writeErr: () => undefined,
|
||||
});
|
||||
registerSkillsCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function runCommand(args: string[]): Promise<void> {
|
||||
await makeProgram().parseAsync(args, { from: "user" });
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function skill(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
companyId: "company-1",
|
||||
key: "paperclip/review-prs",
|
||||
slug: "review-prs",
|
||||
name: "Review PRs",
|
||||
description: "Review pull requests",
|
||||
markdown: "# Review PRs",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: null,
|
||||
createdAt: "2026-05-26T00:00:00.000Z",
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
attachedAgentCount: 2,
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: null,
|
||||
sourceBadge: "local",
|
||||
sourcePath: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogSkill(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "paperclipai:bundled:software-development:github-pr-workflow",
|
||||
key: "paperclipai/bundled/software-development/github-pr-workflow",
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
slug: "github-pr-workflow",
|
||||
name: "github-pr-workflow",
|
||||
description: "Prepare pull requests, review responses, and verification notes.",
|
||||
path: "catalog/bundled/software-development/github-pr-workflow",
|
||||
entrypoint: "SKILL.md",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
defaultInstall: false,
|
||||
recommendedForRoles: ["engineer"],
|
||||
requires: [],
|
||||
tags: ["github", "pull-requests"],
|
||||
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 128, sha256: "sha256:abc" }],
|
||||
contentHash: "sha256:catalog",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function agent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
createdAt: "2026-05-26T00:00:00.000Z",
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("skills CLI helpers", () => {
|
||||
it("resolves skill refs by id, key, or unique normalized slug", () => {
|
||||
const rows = [
|
||||
skill({ id: "skill-a", key: "paperclip/a", slug: "alpha", name: "Alpha" }),
|
||||
skill({ id: "skill-b", key: "paperclip/b", slug: "beta-skill", name: "Beta" }),
|
||||
];
|
||||
|
||||
expect(resolveCompanySkillReference(rows, "skill-a").key).toBe("paperclip/a");
|
||||
expect(resolveCompanySkillReference(rows, "paperclip/b").id).toBe("skill-b");
|
||||
expect(resolveCompanySkillReference(rows, "Beta Skill").id).toBe("skill-b");
|
||||
});
|
||||
|
||||
it("rejects ambiguous slug refs", () => {
|
||||
const rows = [
|
||||
skill({ id: "skill-a", key: "paperclip/a", slug: "same", name: "A" }),
|
||||
skill({ id: "skill-b", key: "paperclip/b", slug: "same", name: "B" }),
|
||||
];
|
||||
|
||||
expect(() => resolveCompanySkillReference(rows, "same")).toThrow(/Ambiguous skill slug/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills CLI commands", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let writeChunks: unknown[];
|
||||
|
||||
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);
|
||||
writeChunks = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
|
||||
writeChunks.push(chunk);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lists company skills as JSON through the shared client context", async () => {
|
||||
const rows = [skill()];
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"list",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/companies/company-1/skills",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ authorization: "Bearer token" }),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
|
||||
});
|
||||
|
||||
it("resolves a skill slug before reading detail", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([skill()]))
|
||||
.mockResolvedValueOnce(jsonResponse({ ...skill(), usedByAgents: [] }));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"show",
|
||||
"Review PRs",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints skill files as raw pipeable content in human mode", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([skill()]))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
skillId: "11111111-1111-1111-1111-111111111111",
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
content: "# Review PRs",
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
editable: true,
|
||||
}));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"file",
|
||||
"review-prs",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
]);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(writeChunks.join("")).toBe("# Review PRs\n");
|
||||
});
|
||||
|
||||
it("browses catalog skills with filters in table output", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([catalogSkill()]));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"browse",
|
||||
"--kind",
|
||||
"bundled",
|
||||
"--category",
|
||||
"software-development",
|
||||
"--query",
|
||||
"github",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/skills/catalog?kind=bundled&category=software-development&q=github",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
const rendered = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(rendered).toContain("id");
|
||||
expect(rendered).toContain("paperclipai:bundled:software-development:github-pr-workflow");
|
||||
expect(rendered).toContain("roles");
|
||||
});
|
||||
|
||||
it("searches catalog skills as JSON", async () => {
|
||||
const rows = [catalogSkill()];
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"search",
|
||||
"pull requests",
|
||||
"--kind",
|
||||
"bundled",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/skills/catalog?kind=bundled&q=pull+requests",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
|
||||
});
|
||||
|
||||
it("inspects catalog skill detail by query ref so keys with slashes work", async () => {
|
||||
const detail = catalogSkill();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(detail));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"inspect",
|
||||
"paperclipai/bundled/software-development/github-pr-workflow",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/skills/catalog/ref?ref=paperclipai%2Fbundled%2Fsoftware-development%2Fgithub-pr-workflow",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(detail);
|
||||
});
|
||||
|
||||
it("installs catalog skills into the company library without agent sync", async () => {
|
||||
const result = {
|
||||
action: "created",
|
||||
skill: skill({
|
||||
key: "paperclipai/bundled/software-development/github-pr-workflow",
|
||||
slug: "pr-flow",
|
||||
sourceType: "catalog",
|
||||
}),
|
||||
catalogSkill: catalogSkill(),
|
||||
warnings: [],
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(result, 201));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"install",
|
||||
"github-pr-workflow",
|
||||
"--as",
|
||||
"pr-flow",
|
||||
"--force",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/companies/company-1/skills/install-catalog",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
catalogSkillId: "github-pr-workflow",
|
||||
slug: "pr-flow",
|
||||
force: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
|
||||
});
|
||||
|
||||
it("passes force to skill updates", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([skill()]))
|
||||
.mockResolvedValueOnce(jsonResponse(skill({ sourceRef: "sha256:new" })));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"update",
|
||||
"review-prs",
|
||||
"--force",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/install-update",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ force: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("audits installed skill bytes through the server", async () => {
|
||||
const audit = {
|
||||
skillId: "11111111-1111-1111-1111-111111111111",
|
||||
installedHash: "sha256:installed",
|
||||
originHash: "sha256:origin",
|
||||
verdict: "warning",
|
||||
codes: ["network_reference"],
|
||||
findings: [{
|
||||
code: "network_reference",
|
||||
severity: "warning",
|
||||
message: "Skill content references network-capable commands or URLs.",
|
||||
path: "SKILL.md",
|
||||
}],
|
||||
scannedAt: "2026-05-26T00:00:00.000Z",
|
||||
scanVersion: "skills-audit-v1",
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([skill()]))
|
||||
.mockResolvedValueOnce(jsonResponse(audit));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"audit",
|
||||
"review-prs",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/audit",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(audit);
|
||||
});
|
||||
|
||||
it("requires confirmation for reset and sends force when confirmed", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([skill({ sourceType: "catalog" })]))
|
||||
.mockResolvedValueOnce(jsonResponse(skill({ sourceType: "catalog" })));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"reset",
|
||||
"review-prs",
|
||||
"--yes",
|
||||
"--force",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/reset",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ force: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs desired company skill refs to an agent and returns the runtime snapshot", async () => {
|
||||
const snapshot = {
|
||||
adapterType: "codex_local",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills: ["paperclip/review-prs"],
|
||||
entries: [
|
||||
{
|
||||
key: "paperclip/review-prs",
|
||||
runtimeName: "review-prs",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "installed",
|
||||
origin: "company_managed",
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse(agent()))
|
||||
.mockResolvedValueOnce(jsonResponse(snapshot));
|
||||
|
||||
await runCommand([
|
||||
"skills",
|
||||
"agent",
|
||||
"sync",
|
||||
"coder",
|
||||
"--skill",
|
||||
"review-prs",
|
||||
"--skill",
|
||||
"paperclip/qa",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://paperclip.test/api/agents/coder?companyId=company-1",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/agents/agent-1/skills/sync",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ desiredSkills: ["review-prs", "paperclip/qa"] }),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
@@ -512,45 +512,6 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves repo-managed worktree checkouts when --force re-runs from the source repo", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-force-preserve-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
const repoConfigDir = path.join(repoRoot, ".paperclip");
|
||||
fs.mkdirSync(repoConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(repoConfigDir, "config.json"), "stale", "utf8");
|
||||
fs.writeFileSync(path.join(repoConfigDir, ".env"), "STALE=1", "utf8");
|
||||
|
||||
// Simulate the repo-managed worktrees subfolder that holds every
|
||||
// worktree checkout (the directory PAPA-358 reported as nuked).
|
||||
const worktreesDir = path.join(repoConfigDir, "worktrees");
|
||||
const checkoutDir = path.join(worktreesDir, "PAP-100-feature");
|
||||
fs.mkdirSync(checkoutDir, { recursive: true });
|
||||
const sentinelPath = path.join(checkoutDir, "sentinel.txt");
|
||||
fs.writeFileSync(sentinelPath, "do-not-delete", "utf8");
|
||||
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeInitCommand({
|
||||
seed: false,
|
||||
force: true,
|
||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(sentinelPath)).toBe(true);
|
||||
expect(fs.readFileSync(sentinelPath, "utf8")).toBe("do-not-delete");
|
||||
expect(fs.existsSync(path.join(repoConfigDir, "config.json"))).toBe(true);
|
||||
expect(fs.readFileSync(path.join(repoConfigDir, "config.json"), "utf8")).not.toBe("stale");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
itEmbeddedPostgres(
|
||||
"seeds authenticated users into minimally cloned worktree instances",
|
||||
async () => {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||
@@ -17,11 +14,6 @@ const claudeLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printClaudeStreamEvent,
|
||||
};
|
||||
|
||||
const acpxLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "acpx_local",
|
||||
formatStdoutEvent: printAcpxStreamEvent,
|
||||
};
|
||||
|
||||
const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "codex_local",
|
||||
formatStdoutEvent: printCodexStreamEvent,
|
||||
@@ -42,21 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const cursorCloudCLIAdapter: CLIAdapterModule = {
|
||||
type: "cursor_cloud",
|
||||
formatStdoutEvent: printCursorCloudEvent,
|
||||
};
|
||||
|
||||
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "gemini_local",
|
||||
formatStdoutEvent: printGeminiStreamEvent,
|
||||
};
|
||||
|
||||
const grokLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "grok_local",
|
||||
formatStdoutEvent: printGrokStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
@@ -64,15 +46,12 @@ const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[
|
||||
acpxLocalCLIAdapter,
|
||||
claudeLocalCLIAdapter,
|
||||
codexLocalCLIAdapter,
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
cursorCloudCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
grokLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
|
||||
@@ -5,9 +5,6 @@ import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
const AWS_CREDENTIAL_SOURCE_HINT =
|
||||
"Provide AWS runtime credentials through the AWS SDK default credential chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials";
|
||||
|
||||
function decodeMasterKey(raw: string): Buffer | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -50,16 +47,13 @@ function withStrictModeNote(
|
||||
|
||||
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||
const provider = config.secrets.provider;
|
||||
if (provider === "aws_secrets_manager") {
|
||||
return withStrictModeNote(awsSecretsManagerCheck(), config);
|
||||
}
|
||||
if (provider !== "local_encrypted") {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message: `${provider} is configured, but this build only supports local_encrypted and aws_secrets_manager`,
|
||||
message: `${provider} is configured, but this build only supports local_encrypted`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section secrets` and choose local_encrypted or aws_secrets_manager",
|
||||
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,100 +135,12 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec
|
||||
};
|
||||
}
|
||||
|
||||
const keyMode = fs.statSync(keyFilePath).mode & 0o777;
|
||||
const permissionWarning =
|
||||
(keyMode & 0o077) !== 0
|
||||
? `; key file permissions are ${keyMode.toString(8)} (run chmod 600 ${keyFilePath})`
|
||||
: "";
|
||||
|
||||
return withStrictModeNote(
|
||||
{
|
||||
name: "Secrets adapter",
|
||||
status: permissionWarning ? "warn" : "pass",
|
||||
message: `Local encrypted provider configured with key file ${keyFilePath}${permissionWarning}`,
|
||||
repairHint: permissionWarning
|
||||
? "Restrict the local encrypted secrets key file to owner read/write permissions"
|
||||
: undefined,
|
||||
status: "pass",
|
||||
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
||||
},
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
function awsSecretsManagerCheck(): CheckResult {
|
||||
const missingConfig = missingAwsSecretsManagerConfig();
|
||||
if (missingConfig.length > 0) {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message: `AWS Secrets Manager provider is missing non-secret config: ${missingConfig.join(", ")}`,
|
||||
canRepair: false,
|
||||
repairHint:
|
||||
`Set ${missingConfig.join(", ")} in the Paperclip server runtime. ${AWS_CREDENTIAL_SOURCE_HINT}. Do not store AWS root credentials or long-lived IAM user keys in Paperclip secrets.`,
|
||||
};
|
||||
}
|
||||
|
||||
const staticEnvCredentials =
|
||||
process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
||||
const credentialSource = detectedAwsCredentialSources().join(", ");
|
||||
const message =
|
||||
`AWS Secrets Manager provider configured for deployment ${process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID}; ` +
|
||||
`runtime credentials source: ${credentialSource || "AWS SDK default credential chain"}`;
|
||||
|
||||
if (staticEnvCredentials) {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "warn",
|
||||
message,
|
||||
canRepair: false,
|
||||
repairHint:
|
||||
"AWS static environment credentials are visible. Use only short-lived shell credentials locally; prefer IAM role/workload identity for hosted deployments and never store AWS access keys in Paperclip company secrets.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "pass",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function missingAwsSecretsManagerConfig(): string[] {
|
||||
const missing: string[] = [];
|
||||
if (
|
||||
!(
|
||||
process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() ||
|
||||
process.env.AWS_REGION?.trim() ||
|
||||
process.env.AWS_DEFAULT_REGION?.trim()
|
||||
)
|
||||
) {
|
||||
missing.push("PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION");
|
||||
}
|
||||
if (!process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim()) {
|
||||
missing.push("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
||||
}
|
||||
if (!process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim()) {
|
||||
missing.push("PAPERCLIP_SECRETS_AWS_KMS_KEY_ID");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function detectedAwsCredentialSources(): string[] {
|
||||
const sources: string[] = [];
|
||||
if (process.env.AWS_PROFILE?.trim()) sources.push("AWS_PROFILE/shared config");
|
||||
if (process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()) {
|
||||
sources.push("temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment credentials");
|
||||
}
|
||||
if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE?.trim() && process.env.AWS_ROLE_ARN?.trim()) {
|
||||
sources.push("AWS web identity token");
|
||||
}
|
||||
if (
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI?.trim() ||
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI?.trim()
|
||||
) {
|
||||
sources.push("AWS container credentials endpoint");
|
||||
}
|
||||
if (process.env.AWS_SHARED_CREDENTIALS_FILE?.trim() || process.env.AWS_CONFIG_FILE?.trim()) {
|
||||
sources.push("custom AWS shared credentials/config file");
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolvePaperclipInstanceRoot } from "../../config/home.js";
|
||||
|
||||
export interface CloudConnectionTokenRecord {
|
||||
id: string;
|
||||
companyStackId: string;
|
||||
targetOrigin: string;
|
||||
sourceInstanceId: string;
|
||||
sourceInstanceFingerprint: string;
|
||||
scopes: string[];
|
||||
expiresAt: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudConnection {
|
||||
id: string;
|
||||
remoteUrl: string;
|
||||
targetOrigin: string;
|
||||
targetHost: string;
|
||||
stackId: string;
|
||||
stackSlug?: string | null;
|
||||
stackDisplayName?: string | null;
|
||||
targetCompanyId: string;
|
||||
accessToken: string;
|
||||
token: CloudConnectionTokenRecord;
|
||||
privateKeyPem: string;
|
||||
sourcePublicKey: string;
|
||||
sourceInstanceId: string;
|
||||
sourceInstanceFingerprint: string;
|
||||
scopes: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CloudConnectionStore {
|
||||
version: 1;
|
||||
connections: Record<string, CloudConnection>;
|
||||
currentConnectionId?: string;
|
||||
}
|
||||
|
||||
function defaultStore(): CloudConnectionStore {
|
||||
return {
|
||||
version: 1,
|
||||
connections: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCloudConnectionStorePath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json");
|
||||
}
|
||||
|
||||
export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore {
|
||||
if (!fs.existsSync(storePath)) return defaultStore();
|
||||
const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial<CloudConnectionStore> | null;
|
||||
const connections: Record<string, CloudConnection> = {};
|
||||
if (raw?.connections && typeof raw.connections === "object") {
|
||||
for (const [id, value] of Object.entries(raw.connections)) {
|
||||
const normalized = normalizeConnection(value);
|
||||
if (normalized) connections[id] = normalized;
|
||||
}
|
||||
}
|
||||
const currentConnectionId =
|
||||
typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId]
|
||||
? raw.currentConnectionId
|
||||
: Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id;
|
||||
return {
|
||||
version: 1,
|
||||
connections,
|
||||
currentConnectionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeCloudConnectionStore(
|
||||
store: CloudConnectionStore,
|
||||
storePath = resolveCloudConnectionStorePath(),
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
||||
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function upsertCloudConnection(
|
||||
connection: CloudConnection,
|
||||
storePath = resolveCloudConnectionStorePath(),
|
||||
): CloudConnection {
|
||||
const store = readCloudConnectionStore(storePath);
|
||||
const existing = store.connections[connection.id];
|
||||
const now = new Date().toISOString();
|
||||
const next = {
|
||||
...connection,
|
||||
createdAt: existing?.createdAt ?? connection.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
store.connections[next.id] = next;
|
||||
store.currentConnectionId = next.id;
|
||||
writeCloudConnectionStore(store, storePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getCloudConnection(
|
||||
remoteUrlOrOrigin?: string,
|
||||
storePath = resolveCloudConnectionStorePath(),
|
||||
): CloudConnection | null {
|
||||
const store = readCloudConnectionStore(storePath);
|
||||
if (remoteUrlOrOrigin?.trim()) {
|
||||
const needle = normalizeRemoteLookup(remoteUrlOrOrigin);
|
||||
return Object.values(store.connections).find((connection) =>
|
||||
normalizeRemoteLookup(connection.remoteUrl) === needle ||
|
||||
normalizeRemoteLookup(connection.targetOrigin) === needle
|
||||
) ?? null;
|
||||
}
|
||||
return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null;
|
||||
}
|
||||
|
||||
function normalizeRemoteLookup(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.origin.replace(/\/+$/u, "");
|
||||
} catch {
|
||||
return value.trim().replace(/\/+$/u, "");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConnection(value: unknown): CloudConnection | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = stringValue(record.id);
|
||||
const remoteUrl = stringValue(record.remoteUrl);
|
||||
const targetOrigin = stringValue(record.targetOrigin);
|
||||
const targetHost = stringValue(record.targetHost);
|
||||
const stackId = stringValue(record.stackId);
|
||||
const targetCompanyId = stringValue(record.targetCompanyId);
|
||||
const accessToken = stringValue(record.accessToken);
|
||||
const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token)
|
||||
? record.token as CloudConnectionTokenRecord
|
||||
: null;
|
||||
const privateKeyPem = stringValue(record.privateKeyPem);
|
||||
const sourcePublicKey = stringValue(record.sourcePublicKey);
|
||||
const sourceInstanceId = stringValue(record.sourceInstanceId);
|
||||
const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint);
|
||||
const createdAt = stringValue(record.createdAt);
|
||||
const updatedAt = stringValue(record.updatedAt);
|
||||
if (
|
||||
!id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId ||
|
||||
!accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId ||
|
||||
!sourceInstanceFingerprint || !createdAt || !updatedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
remoteUrl,
|
||||
targetOrigin,
|
||||
targetHost,
|
||||
stackId,
|
||||
stackSlug: stringValue(record.stackSlug),
|
||||
stackDisplayName: stringValue(record.stackDisplayName),
|
||||
targetCompanyId,
|
||||
accessToken,
|
||||
token,
|
||||
privateKeyPem,
|
||||
sourcePublicKey,
|
||||
sourceInstanceId,
|
||||
sourceInstanceFingerprint,
|
||||
scopes: stringArray(record.scopes),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const upstreamTransferSchema = {
|
||||
family: "paperclip-upstream-transfer",
|
||||
version: "1.0.0",
|
||||
major: 1,
|
||||
minor: 0,
|
||||
} as const;
|
||||
|
||||
export type NormalizedSha256 = `sha256:${string}`;
|
||||
|
||||
export interface SourceEntityKey {
|
||||
sourceInstanceId: string;
|
||||
sourceCompanyId: string;
|
||||
sourceEntityType: string;
|
||||
sourceEntityId: string;
|
||||
sourceNaturalKey?: string;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferWarning {
|
||||
code: string;
|
||||
severity: "info" | "warning" | "blocker";
|
||||
message: string;
|
||||
entity?: SourceEntityKey;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferEntityRecord {
|
||||
key: SourceEntityKey;
|
||||
contentHash: NormalizedSha256;
|
||||
dependencies: SourceEntityKey[];
|
||||
warnings: UpstreamTransferWarning[];
|
||||
}
|
||||
|
||||
export interface UpstreamTransferManifestSource {
|
||||
sourceInstanceId: string;
|
||||
sourceCompanyId: string;
|
||||
sourceInstanceKeyFingerprint: string;
|
||||
exporterVersion: string;
|
||||
sourceSchemaVersion: string;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferManifestTarget {
|
||||
targetStackId: string;
|
||||
targetCompanyId: string;
|
||||
targetOrigin: string;
|
||||
supportedSchemaMajor: number;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferChunk {
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
byteLength: number;
|
||||
sha256: NormalizedSha256;
|
||||
manifestHash: NormalizedSha256;
|
||||
}
|
||||
|
||||
export interface UpstreamTransferManifest {
|
||||
schema: typeof upstreamTransferSchema;
|
||||
source: UpstreamTransferManifestSource;
|
||||
target: UpstreamTransferManifestTarget;
|
||||
runId: string;
|
||||
idempotencyKey: string;
|
||||
generatedAt: string;
|
||||
entityCount: number;
|
||||
entities: UpstreamTransferEntityRecord[];
|
||||
chunks: UpstreamTransferChunk[];
|
||||
warnings: UpstreamTransferWarning[];
|
||||
featureFlags: string[];
|
||||
manifestHash: NormalizedSha256;
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportEntityInput {
|
||||
key: SourceEntityKey;
|
||||
body: Record<string, unknown>;
|
||||
dependencies?: SourceEntityKey[];
|
||||
warnings?: UpstreamTransferWarning[];
|
||||
conflictKeys?: string[];
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportEntity {
|
||||
record: UpstreamTransferEntityRecord;
|
||||
body: Record<string, unknown>;
|
||||
conflictKeys?: string[];
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportChunk {
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
byteLength: number;
|
||||
sha256: NormalizedSha256;
|
||||
payload: {
|
||||
entityKeys: SourceEntityKey[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalUpstreamExportBundle {
|
||||
manifest: UpstreamTransferManifest;
|
||||
entities: LocalUpstreamExportEntity[];
|
||||
chunks: LocalUpstreamExportChunk[];
|
||||
}
|
||||
|
||||
export interface BuildLocalUpstreamExportBundleInput {
|
||||
source: UpstreamTransferManifestSource;
|
||||
target: UpstreamTransferManifestTarget;
|
||||
runId: string;
|
||||
idempotencyKey: string;
|
||||
entities: LocalUpstreamExportEntityInput[];
|
||||
warnings?: UpstreamTransferWarning[];
|
||||
featureFlags?: string[];
|
||||
maxEntitiesPerChunk?: number;
|
||||
}
|
||||
|
||||
export interface LocalUpstreamPushCoordinatorOptions {
|
||||
targetOrigin: string;
|
||||
paperclipCompanyId: string;
|
||||
fetch?: typeof fetch;
|
||||
headers?: (input: { method: string; path: string }) => HeadersInit | Promise<HeadersInit>;
|
||||
}
|
||||
|
||||
export class UpstreamImportRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(status: number, message: string, body: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalUpstreamPushCoordinator {
|
||||
readonly #targetOrigin: string;
|
||||
readonly #paperclipCompanyId: string;
|
||||
readonly #fetch: typeof fetch;
|
||||
readonly #headers: NonNullable<LocalUpstreamPushCoordinatorOptions["headers"]>;
|
||||
|
||||
constructor(options: LocalUpstreamPushCoordinatorOptions) {
|
||||
this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, "");
|
||||
this.#paperclipCompanyId = options.paperclipCompanyId;
|
||||
this.#fetch = options.fetch ?? fetch;
|
||||
this.#headers = options.headers ?? (() => ({}));
|
||||
}
|
||||
|
||||
async preview(bundle: LocalUpstreamExportBundle): Promise<unknown> {
|
||||
return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, {
|
||||
manifest: bundle.manifest,
|
||||
entities: bundle.entities,
|
||||
});
|
||||
}
|
||||
|
||||
async apply(bundle: LocalUpstreamExportBundle): Promise<unknown> {
|
||||
const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, {
|
||||
mode: "apply",
|
||||
manifest: bundle.manifest,
|
||||
entities: bundle.entities,
|
||||
}) as { run?: { id?: unknown } };
|
||||
const runId = typeof run.run?.id === "string" ? run.run.id : undefined;
|
||||
if (!runId) {
|
||||
throw new Error("Remote upstream importer did not return a run id");
|
||||
}
|
||||
|
||||
for (const chunk of bundle.chunks) {
|
||||
await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk);
|
||||
}
|
||||
|
||||
return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {});
|
||||
}
|
||||
|
||||
async events(runId: string): Promise<unknown> {
|
||||
return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<unknown> {
|
||||
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
|
||||
method: "GET",
|
||||
headers: await this.#headers({ method: "GET", path }),
|
||||
});
|
||||
return parseCoordinatorResponse(response);
|
||||
}
|
||||
|
||||
private async post(path: string, body: unknown): Promise<unknown> {
|
||||
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(await this.#headers({ method: "POST", path })),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseCoordinatorResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalUpstreamExportBundle(
|
||||
input: BuildLocalUpstreamExportBundleInput,
|
||||
): LocalUpstreamExportBundle {
|
||||
const entities = input.entities.map<LocalUpstreamExportEntity>((entity) => ({
|
||||
record: {
|
||||
key: entity.key,
|
||||
contentHash: normalizedContentHash(entity.body),
|
||||
dependencies: entity.dependencies ?? [],
|
||||
warnings: entity.warnings ?? [],
|
||||
},
|
||||
body: entity.body,
|
||||
conflictKeys: entity.conflictKeys,
|
||||
}));
|
||||
const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100);
|
||||
const manifestWithoutHash = {
|
||||
schema: upstreamTransferSchema,
|
||||
source: input.source,
|
||||
target: input.target,
|
||||
runId: input.runId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
entityCount: entities.length,
|
||||
entities: entities.map((entity) => entity.record),
|
||||
chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk),
|
||||
warnings: input.warnings ?? [],
|
||||
featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(),
|
||||
};
|
||||
const manifestHash = normalizedContentHash(manifestWithoutHash);
|
||||
return {
|
||||
manifest: {
|
||||
...manifestWithoutHash,
|
||||
chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })),
|
||||
manifestHash,
|
||||
},
|
||||
entities,
|
||||
chunks,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizedContentHash(value: unknown): NormalizedSha256 {
|
||||
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(sortJson(value));
|
||||
}
|
||||
|
||||
function buildLocalChunks(
|
||||
entities: LocalUpstreamExportEntity[],
|
||||
maxEntitiesPerChunk: number,
|
||||
): LocalUpstreamExportChunk[] {
|
||||
if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) {
|
||||
throw new Error("maxEntitiesPerChunk must be a positive integer");
|
||||
}
|
||||
if (entities.length === 0) return [];
|
||||
|
||||
const groups: LocalUpstreamExportEntity[][] = [];
|
||||
for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) {
|
||||
groups.push(entities.slice(index, index + maxEntitiesPerChunk));
|
||||
}
|
||||
|
||||
return groups.map((group, index) => {
|
||||
const payload = {
|
||||
entityKeys: group.map((entity) => entity.record.key),
|
||||
};
|
||||
return {
|
||||
chunkIndex: index,
|
||||
totalChunks: groups.length,
|
||||
byteLength: Buffer.byteLength(canonicalJson(payload)),
|
||||
sha256: normalizedContentHash(payload),
|
||||
payload,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sortJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortJson);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, sortJson(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
async function parseCoordinatorResponse(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
const parsed = text.trim() ? safeParseJson(text) : {};
|
||||
if (!response.ok) {
|
||||
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
|
||||
? String((parsed as { error: unknown }).error)
|
||||
: `Upstream importer request failed with ${response.status}`;
|
||||
throw new UpstreamImportRequestError(response.status, message, parsed);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function safeParseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -1,721 +0,0 @@
|
||||
import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { URL } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import type {
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityFileEntry,
|
||||
InstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import { openUrl } from "../../client/board-auth.js";
|
||||
import { resolvePaperclipInstanceId } from "../../config/home.js";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
import {
|
||||
buildLocalUpstreamExportBundle,
|
||||
LocalUpstreamPushCoordinator,
|
||||
normalizedContentHash,
|
||||
upstreamTransferSchema,
|
||||
UpstreamImportRequestError,
|
||||
type LocalUpstreamExportBundle,
|
||||
type LocalUpstreamExportEntityInput,
|
||||
type SourceEntityKey,
|
||||
type UpstreamTransferManifestSource,
|
||||
type UpstreamTransferManifestTarget,
|
||||
type UpstreamTransferWarning,
|
||||
} from "./cloud-transfer.js";
|
||||
import {
|
||||
getCloudConnection,
|
||||
upsertCloudConnection,
|
||||
type CloudConnection,
|
||||
type CloudConnectionTokenRecord,
|
||||
} from "./cloud-store.js";
|
||||
|
||||
const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2;
|
||||
const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3;
|
||||
const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
|
||||
const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000;
|
||||
|
||||
interface CloudConnectOptions extends BaseClientOptions {
|
||||
noBrowser?: boolean;
|
||||
}
|
||||
|
||||
interface CloudPushOptions extends BaseClientOptions {
|
||||
company?: string;
|
||||
remoteUrl?: string;
|
||||
dryRun?: boolean;
|
||||
maxEntitiesPerChunk?: number;
|
||||
}
|
||||
|
||||
interface UpstreamDiscovery {
|
||||
schema: string;
|
||||
stack: {
|
||||
id: string;
|
||||
slug?: string;
|
||||
displayName?: string;
|
||||
companyId: string;
|
||||
origin: string;
|
||||
};
|
||||
auth: {
|
||||
pkce?: {
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
codeChallengeMethod: string;
|
||||
};
|
||||
deviceCode?: {
|
||||
deviceCodeUrl: string;
|
||||
verificationUrl: string;
|
||||
tokenUrl: string;
|
||||
};
|
||||
scopes?: string[];
|
||||
};
|
||||
transfer: {
|
||||
supportedSchemaMajor: number;
|
||||
featureFlags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
accessToken: string;
|
||||
token: CloudConnectionTokenRecord;
|
||||
scopes?: string[];
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
class CloudAuthRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(status: number, message: string, body: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCloudCommands(program: Command): void {
|
||||
const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands");
|
||||
|
||||
addCommonClientOptions(
|
||||
cloud
|
||||
.command("connect")
|
||||
.description("Authorize this local instance to push into a Paperclip Cloud stack")
|
||||
.argument("<remote-url>", "Paperclip Cloud stack URL")
|
||||
.option("--no-browser", "Use the device-code flow instead of opening a browser", false)
|
||||
.action(async (remoteUrl: string, opts: CloudConnectOptions) => {
|
||||
try {
|
||||
await connectCloud(remoteUrl, opts);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
cloud
|
||||
.command("push")
|
||||
.description("Preview or apply a local company push into the connected Paperclip Cloud stack")
|
||||
.requiredOption("--company <local-company-id>", "Local company ID to export")
|
||||
.option("--remote-url <remote-url>", "Use a specific stored cloud connection")
|
||||
.option("--dry-run", "Preview without applying", false)
|
||||
.option("--max-entities-per-chunk <count>", "Chunk size for upstream uploads", (value) => Number(value), 100)
|
||||
.action(async (opts: CloudPushOptions) => {
|
||||
try {
|
||||
await pushCloud(opts);
|
||||
} catch (err) {
|
||||
if (isSchemaMismatchError(err)) {
|
||||
console.error(pc.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE;
|
||||
return;
|
||||
}
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise<CloudConnection> {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const discovery = await discoverUpstream(remoteUrl);
|
||||
assertDiscoveryCompatible(discovery);
|
||||
const source = createSourceIdentity();
|
||||
const token = await authorizeConnection(discovery, source, {
|
||||
noBrowser: Boolean(opts.noBrowser),
|
||||
});
|
||||
const targetOrigin = discovery.stack.origin.replace(/\/+$/u, "");
|
||||
const targetHost = new URL(targetOrigin).host;
|
||||
const now = new Date().toISOString();
|
||||
const connection = upsertCloudConnection({
|
||||
id: connectionId(targetOrigin),
|
||||
remoteUrl,
|
||||
targetOrigin,
|
||||
targetHost,
|
||||
stackId: discovery.stack.id,
|
||||
stackSlug: discovery.stack.slug ?? null,
|
||||
stackDisplayName: discovery.stack.displayName ?? null,
|
||||
targetCompanyId: discovery.stack.companyId,
|
||||
accessToken: token.accessToken,
|
||||
token: token.token,
|
||||
privateKeyPem: source.privateKeyPem,
|
||||
sourcePublicKey: source.sourcePublicKey,
|
||||
sourceInstanceId: source.sourceInstanceId,
|
||||
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
|
||||
scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(redactConnection(connection), { json: true });
|
||||
} else {
|
||||
console.log(pc.bold("Connected to Paperclip Cloud"));
|
||||
console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`);
|
||||
console.log(`origin=${connection.targetOrigin}`);
|
||||
console.log(`company=${connection.targetCompanyId}`);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function pushCloud(opts: CloudPushOptions): Promise<unknown> {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: false });
|
||||
const localCompanyId = requiredString(opts.company, "--company");
|
||||
await assertCloudSyncEnabled(ctx.api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"));
|
||||
const connection = getCloudConnection(opts.remoteUrl);
|
||||
if (!connection) {
|
||||
throw new Error("No cloud connection found. Run `paperclipai cloud connect <remote-url>` first.");
|
||||
}
|
||||
|
||||
const discovery = await discoverUpstream(connection.targetOrigin);
|
||||
assertDiscoveryCompatible(discovery);
|
||||
const bundle = await buildBundleFromLocalCompany({
|
||||
localCompanyId,
|
||||
connection,
|
||||
discovery,
|
||||
localApi: ctx.api,
|
||||
maxEntitiesPerChunk: opts.maxEntitiesPerChunk,
|
||||
mode: opts.dryRun ? "preview" : "apply",
|
||||
});
|
||||
const coordinator = new LocalUpstreamPushCoordinator({
|
||||
targetOrigin: connection.targetOrigin,
|
||||
paperclipCompanyId: connection.targetCompanyId,
|
||||
headers: ({ method, path }) => cloudProofHeaders(connection, method, path),
|
||||
});
|
||||
|
||||
const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle);
|
||||
const runId = getRunId(result);
|
||||
const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null;
|
||||
const summary = summarizeResult(result);
|
||||
const conflictCount = summary.conflict + summary.staleMapping;
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput({ result, events }, { json: true });
|
||||
} else {
|
||||
console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied"));
|
||||
console.log(`run=${runId ?? "-"}`);
|
||||
console.log(`manifest=${bundle.manifest.manifestHash}`);
|
||||
console.log(
|
||||
`create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` +
|
||||
`skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`,
|
||||
);
|
||||
printWarnings(result);
|
||||
printConflicts(result);
|
||||
printEvents(events);
|
||||
}
|
||||
|
||||
if (conflictCount > 0) {
|
||||
process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function discoverUpstream(remoteUrl: string): Promise<UpstreamDiscovery> {
|
||||
const base = new URL(remoteUrl);
|
||||
const discoveryUrl = new URL("/.well-known/paperclip-upstream", base);
|
||||
return requestCloudJson<UpstreamDiscovery>(discoveryUrl.toString(), { method: "GET" });
|
||||
}
|
||||
|
||||
export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void {
|
||||
if (discovery.schema !== "paperclip-upstream-discovery-v1") {
|
||||
throw new Error("Remote URL is not a Paperclip Cloud upstream target.");
|
||||
}
|
||||
if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) {
|
||||
throw new Error(
|
||||
`Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`,
|
||||
);
|
||||
}
|
||||
if (!discovery.transfer.featureFlags?.includes("cloud_sync")) {
|
||||
throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag.");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number {
|
||||
const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS;
|
||||
}
|
||||
|
||||
export async function buildBundleFromLocalCompany(input: {
|
||||
localCompanyId: string;
|
||||
connection: CloudConnection;
|
||||
discovery: UpstreamDiscovery;
|
||||
localApi: {
|
||||
post<T>(path: string, body?: unknown): Promise<T | null>;
|
||||
};
|
||||
maxEntitiesPerChunk?: number;
|
||||
mode: "preview" | "apply";
|
||||
}): Promise<LocalUpstreamExportBundle> {
|
||||
const exported = await input.localApi.post<CompanyPortabilityExportResult>(
|
||||
`/api/companies/${input.localCompanyId}/export`,
|
||||
{
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
expandReferencedSkills: true,
|
||||
},
|
||||
);
|
||||
if (!exported) throw new Error("Local company export returned no data.");
|
||||
|
||||
const sourceHash = normalizedContentHash({
|
||||
manifest: exported.manifest,
|
||||
files: exported.files,
|
||||
});
|
||||
const source: UpstreamTransferManifestSource = {
|
||||
sourceInstanceId: input.connection.sourceInstanceId,
|
||||
sourceCompanyId: input.localCompanyId,
|
||||
sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint,
|
||||
exporterVersion: "paperclipai-cli-cloud-v1",
|
||||
sourceSchemaVersion: "paperclip-local-portability-v1",
|
||||
};
|
||||
const target: UpstreamTransferManifestTarget = {
|
||||
targetStackId: input.discovery.stack.id,
|
||||
targetCompanyId: input.discovery.stack.companyId,
|
||||
targetOrigin: input.discovery.stack.origin,
|
||||
supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor,
|
||||
};
|
||||
const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported);
|
||||
const idempotencyKey = [
|
||||
input.mode,
|
||||
input.connection.sourceInstanceId,
|
||||
input.localCompanyId,
|
||||
input.discovery.stack.id,
|
||||
sourceHash,
|
||||
].join(":");
|
||||
return buildLocalUpstreamExportBundle({
|
||||
source,
|
||||
target,
|
||||
runId: `local-${input.mode}-${shortHash(idempotencyKey)}`,
|
||||
idempotencyKey,
|
||||
entities,
|
||||
warnings: exported.warnings.map((message): UpstreamTransferWarning => ({
|
||||
code: "local_company_export_warning",
|
||||
severity: "warning",
|
||||
message,
|
||||
})),
|
||||
featureFlags: ["cloud_sync"],
|
||||
maxEntitiesPerChunk: input.maxEntitiesPerChunk,
|
||||
});
|
||||
}
|
||||
|
||||
async function authorizeConnection(
|
||||
discovery: UpstreamDiscovery,
|
||||
source: ReturnType<typeof createSourceIdentity>,
|
||||
opts: { noBrowser: boolean },
|
||||
): Promise<TokenResponse> {
|
||||
if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) {
|
||||
try {
|
||||
return await authorizeWithBrowser(discovery, source);
|
||||
} catch (error) {
|
||||
console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`));
|
||||
}
|
||||
}
|
||||
if (!discovery.auth.deviceCode) {
|
||||
throw new Error("Remote Paperclip Cloud stack does not support device-code authorization.");
|
||||
}
|
||||
return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() });
|
||||
}
|
||||
|
||||
async function authorizeWithBrowser(
|
||||
discovery: UpstreamDiscovery,
|
||||
source: ReturnType<typeof createSourceIdentity>,
|
||||
): Promise<TokenResponse> {
|
||||
const pkce = discovery.auth.pkce;
|
||||
if (!pkce) throw new Error("Remote did not advertise PKCE authorization.");
|
||||
const callback = await startPkceCallbackServer();
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
const state = randomUUID();
|
||||
const authorizeUrl = new URL(pkce.authorizeUrl);
|
||||
authorizeUrl.searchParams.set("redirectUri", callback.redirectUri);
|
||||
authorizeUrl.searchParams.set("state", state);
|
||||
authorizeUrl.searchParams.set("codeChallenge", challenge);
|
||||
authorizeUrl.searchParams.set("codeChallengeMethod", "S256");
|
||||
authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId);
|
||||
authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint);
|
||||
authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey);
|
||||
authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" "));
|
||||
|
||||
try {
|
||||
console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`);
|
||||
if (!openUrl(authorizeUrl.toString())) {
|
||||
throw new Error("Could not open a browser.");
|
||||
}
|
||||
const code = await callback.waitForCode(state);
|
||||
return requestCloudJson<TokenResponse>(pkce.tokenUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
grantType: "authorization_code",
|
||||
code,
|
||||
redirectUri: callback.redirectUri,
|
||||
codeVerifier: verifier,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function authorizeWithDeviceCode(
|
||||
discovery: UpstreamDiscovery,
|
||||
source: ReturnType<typeof createSourceIdentity>,
|
||||
opts: { openBrowser: boolean },
|
||||
): Promise<TokenResponse> {
|
||||
const device = discovery.auth.deviceCode;
|
||||
if (!device) throw new Error("Remote did not advertise device-code authorization.");
|
||||
const response = await requestCloudJson<{
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresAt?: string;
|
||||
intervalSeconds?: number;
|
||||
}>(device.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
stackId: discovery.stack.id,
|
||||
sourceInstanceId: source.sourceInstanceId,
|
||||
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
|
||||
sourcePublicKey: source.sourcePublicKey,
|
||||
scopes: CLOUD_SYNC_SCOPES,
|
||||
}),
|
||||
});
|
||||
console.error(pc.bold("Cloud device authorization required"));
|
||||
console.error(`Open: ${response.verificationUri}`);
|
||||
console.error(`Code: ${response.userCode}`);
|
||||
if (opts.openBrowser) openUrl(response.verificationUri);
|
||||
|
||||
const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt);
|
||||
const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000);
|
||||
while (Date.now() < expiresAt) {
|
||||
await sleep(intervalMs);
|
||||
try {
|
||||
return await requestCloudJson<TokenResponse>(device.tokenUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
grantType: "device_code",
|
||||
deviceCode: response.deviceCode,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") {
|
||||
const code = (error.body as { error?: unknown }).error;
|
||||
if (code === "authorization_pending") continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Device-code authorization expired before it was approved.");
|
||||
}
|
||||
|
||||
function buildEntitiesFromPortableExport(
|
||||
localCompanyId: string,
|
||||
sourceInstanceId: string,
|
||||
exported: CompanyPortabilityExportResult,
|
||||
): LocalUpstreamExportEntityInput[] {
|
||||
const companyKey: SourceEntityKey = {
|
||||
sourceInstanceId,
|
||||
sourceCompanyId: localCompanyId,
|
||||
sourceEntityType: "company",
|
||||
sourceEntityId: localCompanyId,
|
||||
sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId,
|
||||
};
|
||||
const entities: LocalUpstreamExportEntityInput[] = [
|
||||
{
|
||||
key: companyKey,
|
||||
body: {
|
||||
kind: "paperclip_company_portability_manifest",
|
||||
manifest: exported.manifest,
|
||||
rootPath: exported.rootPath,
|
||||
paperclipExtensionPath: exported.paperclipExtensionPath,
|
||||
fileCount: Object.keys(exported.files).length,
|
||||
},
|
||||
conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`],
|
||||
},
|
||||
];
|
||||
|
||||
for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
entities.push({
|
||||
key: {
|
||||
sourceInstanceId,
|
||||
sourceCompanyId: localCompanyId,
|
||||
sourceEntityType: "company_setting",
|
||||
sourceEntityId: shortHash(filePath),
|
||||
sourceNaturalKey: filePath,
|
||||
},
|
||||
body: {
|
||||
kind: "paperclip_portable_file",
|
||||
path: filePath,
|
||||
entry: normalizePortableFileEntry(entry),
|
||||
},
|
||||
dependencies: [companyKey],
|
||||
conflictKeys: [`portable_file:${filePath}`],
|
||||
});
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record<string, unknown> {
|
||||
if (typeof entry === "string") {
|
||||
return { encoding: "utf8", data: entry };
|
||||
}
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
async function assertCloudSyncEnabled(settingsPromise: Promise<InstanceExperimentalSettings | null>): Promise<void> {
|
||||
const settings = await settingsPromise;
|
||||
if (settings?.enableCloudSync !== true) {
|
||||
throw new Error(
|
||||
"Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record<string, string> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = randomUUID();
|
||||
const payload = [
|
||||
method,
|
||||
connection.targetHost.toLowerCase(),
|
||||
pathAndSearch,
|
||||
connection.token.id,
|
||||
connection.sourceInstanceId,
|
||||
timestamp,
|
||||
nonce,
|
||||
].join("\n");
|
||||
return {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
"X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId,
|
||||
"X-Paperclip-Upstream-Proof-Timestamp": timestamp,
|
||||
"X-Paperclip-Upstream-Proof-Nonce": nonce,
|
||||
"X-Paperclip-Upstream-Proof-Signature": sign(
|
||||
null,
|
||||
Buffer.from(payload, "utf8"),
|
||||
connection.privateKeyPem,
|
||||
).toString("base64url"),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCloudJson<T>(url: string, init: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("accept", "application/json");
|
||||
if (init.body !== undefined && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
const text = await response.text();
|
||||
const parsed = text.trim() ? JSON.parse(text) as unknown : {};
|
||||
if (!response.ok) {
|
||||
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
|
||||
? String((parsed as { error: unknown }).error)
|
||||
: `Cloud request failed with ${response.status}`;
|
||||
throw new CloudAuthRequestError(response.status, message, parsed);
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
function createSourceIdentity() {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString();
|
||||
const sourceInstanceFingerprint = `sha256:${createHash("sha256")
|
||||
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||
.digest("hex")}`;
|
||||
return {
|
||||
sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`,
|
||||
sourceInstanceFingerprint,
|
||||
sourcePublicKey,
|
||||
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function startPkceCallbackServer(): Promise<{
|
||||
redirectUri: string;
|
||||
waitForCode: (state: string) => Promise<string>;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
let resolveCode: ((code: string) => void) | null = null;
|
||||
let rejectCode: ((error: Error) => void) | null = null;
|
||||
let expectedState = "";
|
||||
const codePromise = new Promise<string>((resolve, reject) => {
|
||||
resolveCode = resolve;
|
||||
rejectCode = reject;
|
||||
});
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code || state !== expectedState) {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Paperclip Cloud authorization failed. You can close this tab.");
|
||||
rejectCode?.(new Error("Authorization callback was missing a valid code or state."));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("Paperclip Cloud authorization complete. You can close this tab.");
|
||||
resolveCode?.(code);
|
||||
});
|
||||
await listenOnLoopback(server);
|
||||
const address = server.address();
|
||||
if (typeof address !== "object" || !address?.port) {
|
||||
throw new Error("Failed to start local authorization callback server.");
|
||||
}
|
||||
return {
|
||||
redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`,
|
||||
waitForCode: (state: string) => {
|
||||
expectedState = state;
|
||||
return codePromise;
|
||||
},
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
}
|
||||
|
||||
function listenOnLoopback(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function canOpenBrowser(): boolean {
|
||||
if (process.platform === "darwin" || process.platform === "win32") return true;
|
||||
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
||||
}
|
||||
|
||||
function summarizeResult(result: unknown): {
|
||||
create: number;
|
||||
update: number;
|
||||
adopt: number;
|
||||
skip: number;
|
||||
conflict: number;
|
||||
staleMapping: number;
|
||||
} {
|
||||
const summary = asRecord(asRecord(result)?.summary);
|
||||
return {
|
||||
create: numberValue(summary?.create),
|
||||
update: numberValue(summary?.update),
|
||||
adopt: numberValue(summary?.adopt),
|
||||
skip: numberValue(summary?.skip),
|
||||
conflict: numberValue(summary?.conflict),
|
||||
staleMapping: numberValue(summary?.staleMapping),
|
||||
};
|
||||
}
|
||||
|
||||
function printWarnings(result: unknown): void {
|
||||
const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : [];
|
||||
for (const warning of warnings) {
|
||||
const record = asRecord(warning);
|
||||
console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
function printConflicts(result: unknown): void {
|
||||
const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : [];
|
||||
for (const conflict of conflicts.slice(0, 10)) {
|
||||
const record = asRecord(conflict);
|
||||
console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`));
|
||||
}
|
||||
if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`));
|
||||
}
|
||||
|
||||
function printEvents(events: unknown): void {
|
||||
const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : [];
|
||||
for (const row of rows.slice(-10)) {
|
||||
const event = asRecord(row);
|
||||
console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`));
|
||||
}
|
||||
}
|
||||
|
||||
function getRunId(result: unknown): string | null {
|
||||
const run = asRecord(asRecord(result)?.run);
|
||||
return typeof run?.id === "string" ? run.id : null;
|
||||
}
|
||||
|
||||
function redactConnection(connection: CloudConnection): Record<string, unknown> {
|
||||
return {
|
||||
id: connection.id,
|
||||
remoteUrl: connection.remoteUrl,
|
||||
targetOrigin: connection.targetOrigin,
|
||||
stackId: connection.stackId,
|
||||
targetCompanyId: connection.targetCompanyId,
|
||||
scopes: connection.scopes,
|
||||
expiresAt: connection.token.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function connectionId(targetOrigin: string): string {
|
||||
return `cloud-${shortHash(targetOrigin)}`;
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
throw new Error(`${label} is required.`);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function isSchemaMismatchError(error: unknown): boolean {
|
||||
if (error instanceof UpstreamImportRequestError) {
|
||||
return JSON.stringify(error.body).toLowerCase().includes("schema");
|
||||
}
|
||||
return error instanceof Error && error.message.toLowerCase().includes("schema mismatch");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const cloudCommandExitCodes = {
|
||||
conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE,
|
||||
schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE,
|
||||
} as const;
|
||||
@@ -1,11 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { Command, Option } from "commander";
|
||||
import {
|
||||
scaffoldPluginProject,
|
||||
shellQuote,
|
||||
type ScaffoldPluginOptions,
|
||||
} from "../../../../packages/plugins/create-paperclip-plugin/src/index.js";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
@@ -45,101 +39,28 @@ interface PluginInstallOptions extends BaseClientOptions {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallRequest {
|
||||
packageName: string;
|
||||
version?: string;
|
||||
isLocalPath: boolean;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface PluginInitOptions extends BaseClientOptions {
|
||||
output?: string;
|
||||
template?: ScaffoldPluginOptions["template"];
|
||||
category?: ScaffoldPluginOptions["category"];
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
interface PluginInitResult {
|
||||
outputDir: string;
|
||||
nextCommands: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function expandHomePath(packageArg: string): string {
|
||||
if (!packageArg.startsWith("~")) return packageArg;
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
|
||||
function hasLocalPathSyntax(packageArg: string): boolean {
|
||||
return (
|
||||
path.isAbsolute(packageArg) ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("~") ||
|
||||
packageArg.startsWith(".\\") ||
|
||||
packageArg.startsWith("..\\")
|
||||
);
|
||||
}
|
||||
|
||||
function isExistingRelativePath(
|
||||
packageArg: string,
|
||||
cwd: string,
|
||||
pathExists: (targetPath: string) => boolean,
|
||||
): boolean {
|
||||
if (packageArg.trim() === "") return false;
|
||||
if (hasLocalPathSyntax(packageArg)) return false;
|
||||
return pathExists(path.resolve(cwd, packageArg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): string {
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
if (packageArg.startsWith("~")) return expandHomePath(packageArg);
|
||||
return path.resolve(cwd, packageArg);
|
||||
}
|
||||
|
||||
export function buildPluginInstallRequest(
|
||||
packageArg: string,
|
||||
opts: Pick<PluginInstallOptions, "local" | "version"> = {},
|
||||
deps: { cwd?: string; existsSync?: (targetPath: string) => boolean } = {},
|
||||
): PluginInstallRequest {
|
||||
const cwd = deps.cwd ?? process.cwd();
|
||||
const pathExists = deps.existsSync ?? existsSync;
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
hasLocalPathSyntax(packageArg) ||
|
||||
(opts.version ? false : isExistingRelativePath(packageArg, cwd, pathExists));
|
||||
|
||||
if (isLocal && opts.version) {
|
||||
throw new Error("--version is only supported for npm package installs, not local plugin paths.");
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
|
||||
return {
|
||||
packageName: resolvePackageArg(packageArg, Boolean(isLocal), cwd),
|
||||
version: opts.version,
|
||||
isLocalPath: Boolean(isLocal),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLocalPluginInstallHint(packagePath: string): string {
|
||||
return [
|
||||
pc.dim("Local plugin installs run trusted local code from your machine."),
|
||||
pc.dim(`Keep ${pc.cyan("pnpm dev")} running in ${packagePath}; Paperclip watches rebuilt dist output and reloads the plugin worker.`),
|
||||
].join("\n");
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
@@ -166,58 +87,6 @@ function formatPlugin(p: PluginRecord): string {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function packageToDirName(pluginName: string): string {
|
||||
return pluginName.replace(/^@[^/]+\//, "");
|
||||
}
|
||||
|
||||
export function buildPluginInitScaffoldOptions(
|
||||
packageName: string,
|
||||
opts: PluginInitOptions,
|
||||
cwd = process.cwd(),
|
||||
): ScaffoldPluginOptions {
|
||||
const outputRoot = path.resolve(cwd, opts.output ?? ".");
|
||||
const outputDir = path.resolve(outputRoot, packageToDirName(packageName));
|
||||
|
||||
return {
|
||||
pluginName: packageName,
|
||||
outputDir,
|
||||
template: opts.template,
|
||||
category: opts.category,
|
||||
displayName: opts.displayName,
|
||||
description: opts.description,
|
||||
author: opts.author,
|
||||
sdkPath: opts.sdkPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginInitNextCommands(outputDir: string): string[] {
|
||||
const quotedOutputDir = shellQuote(outputDir);
|
||||
return [
|
||||
`cd ${quotedOutputDir}`,
|
||||
"pnpm install",
|
||||
"pnpm dev",
|
||||
`paperclipai plugin install ${quotedOutputDir}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderPluginInitSuccess(result: PluginInitResult): string {
|
||||
return [
|
||||
pc.green(`✓ Created plugin scaffold at ${result.outputDir}`),
|
||||
"",
|
||||
"Next commands:",
|
||||
...result.nextCommands.map((command) => ` ${pc.cyan(command)}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function runPluginInitCommand(packageName: string, opts: PluginInitOptions): PluginInitResult {
|
||||
const scaffoldOptions = buildPluginInitScaffoldOptions(packageName, opts);
|
||||
const outputDir = scaffoldPluginProject(scaffoldOptions);
|
||||
return {
|
||||
outputDir,
|
||||
nextCommands: buildPluginInitNextCommands(outputDir),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -225,43 +94,6 @@ export function runPluginInitCommand(packageName: string, opts: PluginInitOption
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin init <package-name>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("init <packageName>")
|
||||
.description("Scaffold a local Paperclip plugin project")
|
||||
.option("--output <dir>", "Directory to create the plugin folder in")
|
||||
.addOption(
|
||||
new Option("--template <template>", "Starter template")
|
||||
.choices(["default", "connector", "workspace", "environment"])
|
||||
.default("default"),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--category <category>", "Manifest category")
|
||||
.choices(["connector", "workspace", "automation", "ui", "environment"]),
|
||||
)
|
||||
.option("--display-name <name>", "Manifest display name")
|
||||
.option("--description <description>", "Manifest description")
|
||||
.option("--author <author>", "Manifest author")
|
||||
.option("--sdk-path <path>", "Local @paperclipai/plugin-sdk package path")
|
||||
.action((packageName: string, opts: PluginInitOptions) => {
|
||||
try {
|
||||
const result = runPluginInitCommand(packageName, opts);
|
||||
|
||||
if (opts.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(renderPluginInitSuccess(result));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -315,19 +147,31 @@ export function registerPluginCommands(program: Command): void {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
const installRequest = buildPluginInstallRequest(packageArg, opts);
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
installRequest.isLocalPath
|
||||
? `Installing plugin from local path: ${installRequest.packageName}`
|
||||
: `Installing plugin: ${installRequest.packageName}${opts.version ? `@${opts.version}` : ""}`,
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", installRequest);
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
@@ -348,10 +192,6 @@ export function registerPluginCommands(program: Command): void {
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
|
||||
if (installRequest.isLocalPath) {
|
||||
console.log(renderLocalPluginInstallHint(installRequest.packageName));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import type {
|
||||
Agent,
|
||||
AgentEnvConfig,
|
||||
CompanyPortabilityEnvInput,
|
||||
CompanyPortabilityExportPreviewResult,
|
||||
CompanyPortabilityInclude,
|
||||
CompanySecret,
|
||||
EnvBinding,
|
||||
SecretProvider,
|
||||
SecretProviderDescriptor,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface SecretListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface SecretDeclarationsOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
include?: string;
|
||||
kind?: "all" | "secret" | "plain";
|
||||
}
|
||||
|
||||
interface SecretCreateOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
name?: string;
|
||||
key?: string;
|
||||
provider?: SecretProvider;
|
||||
value?: string;
|
||||
valueEnv?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SecretLinkOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
name?: string;
|
||||
key?: string;
|
||||
provider?: SecretProvider;
|
||||
externalRef?: string;
|
||||
providerVersionRef?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SecretDoctorOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface SecretMigrateInlineEnvOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
apply?: boolean;
|
||||
}
|
||||
|
||||
interface SecretProviderHealth {
|
||||
provider: SecretProvider;
|
||||
status: "ok" | "warn" | "error";
|
||||
message: string;
|
||||
warnings?: string[];
|
||||
backupGuidance?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SecretProviderHealthResponse {
|
||||
providers: SecretProviderHealth[];
|
||||
}
|
||||
|
||||
export interface InlineSecretMigrationCandidate {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
envKey: string;
|
||||
secretName: string;
|
||||
existingSecretId: string | null;
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(^token$|[-_]?token$|api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
const DEFAULT_DECLARATION_INCLUDE: CompanyPortabilityInclude = {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: false,
|
||||
skills: false,
|
||||
};
|
||||
|
||||
export function parseSecretsInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||
if (!input?.trim()) return { ...DEFAULT_DECLARATION_INCLUDE };
|
||||
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||
const include = {
|
||||
company: values.includes("company"),
|
||||
agents: values.includes("agents"),
|
||||
projects: values.includes("projects"),
|
||||
issues: values.includes("issues") || values.includes("tasks"),
|
||||
skills: values.includes("skills"),
|
||||
};
|
||||
if (!Object.values(include).some(Boolean)) {
|
||||
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
|
||||
}
|
||||
return include;
|
||||
}
|
||||
|
||||
export function isSensitiveEnvKey(key: string): boolean {
|
||||
return SENSITIVE_ENV_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
export function toPlainEnvValue(binding: unknown): string | null {
|
||||
if (typeof binding === "string") return binding;
|
||||
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
|
||||
const record = binding as Record<string, unknown>;
|
||||
if (record.type === "plain" && typeof record.value === "string") return record.value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildInlineMigrationSecretName(agentId: string, key: string): string {
|
||||
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function collectInlineSecretMigrationCandidates(
|
||||
agents: Agent[],
|
||||
existingSecrets: CompanySecret[],
|
||||
): InlineSecretMigrationCandidate[] {
|
||||
const secretByName = new Map(existingSecrets.map((secret) => [secret.name, secret]));
|
||||
const candidates: InlineSecretMigrationCandidate[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
const env = asRecord(agent.adapterConfig.env);
|
||||
if (!env) continue;
|
||||
for (const [envKey, binding] of Object.entries(env)) {
|
||||
if (!isSensitiveEnvKey(envKey)) continue;
|
||||
const plain = toPlainEnvValue(binding);
|
||||
if (plain === null || plain.trim().length === 0) continue;
|
||||
const secretName = buildInlineMigrationSecretName(agent.id, envKey);
|
||||
candidates.push({
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
envKey,
|
||||
secretName,
|
||||
existingSecretId: secretByName.get(secretName)?.id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function buildMigratedAgentEnv(
|
||||
env: Record<string, unknown>,
|
||||
secretIdByEnvKey: Map<string, string>,
|
||||
): AgentEnvConfig {
|
||||
const next: AgentEnvConfig = { ...(env as Record<string, EnvBinding>) };
|
||||
for (const [envKey, secretId] of secretIdByEnvKey) {
|
||||
next[envKey] = {
|
||||
type: "secret_ref",
|
||||
secretId,
|
||||
version: "latest",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readValueFromOptions(opts: SecretCreateOptions): string {
|
||||
if (opts.value !== undefined && opts.valueEnv !== undefined) {
|
||||
throw new Error("Use only one of --value or --value-env.");
|
||||
}
|
||||
if (opts.valueEnv !== undefined) {
|
||||
const value = process.env[opts.valueEnv];
|
||||
if (!value) throw new Error(`Environment variable ${opts.valueEnv} is empty or unset.`);
|
||||
return value;
|
||||
}
|
||||
if (opts.value !== undefined) return opts.value;
|
||||
throw new Error("Secret value is required. Pass --value or --value-env.");
|
||||
}
|
||||
|
||||
function renderDeclaration(input: CompanyPortabilityEnvInput): Record<string, unknown> {
|
||||
const scope = input.agentSlug
|
||||
? `agent:${input.agentSlug}`
|
||||
: input.projectSlug
|
||||
? `project:${input.projectSlug}`
|
||||
: "company";
|
||||
return {
|
||||
key: input.key,
|
||||
scope,
|
||||
kind: input.kind,
|
||||
requirement: input.requirement,
|
||||
portability: input.portability,
|
||||
hasDefault: input.defaultValue !== null && input.defaultValue.length > 0,
|
||||
description: input.description,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSecret(secret: CompanySecret): Record<string, unknown> {
|
||||
return {
|
||||
id: secret.id,
|
||||
name: secret.name,
|
||||
key: secret.key,
|
||||
provider: secret.provider,
|
||||
status: secret.status,
|
||||
managedMode: secret.managedMode,
|
||||
latestVersion: secret.latestVersion,
|
||||
externalRef: secret.externalRef ? "yes" : "no",
|
||||
};
|
||||
}
|
||||
|
||||
function printProviderHealth(rows: SecretProviderHealth[], json: boolean): void {
|
||||
if (json) {
|
||||
printOutput(rows, { json: true });
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
printOutput([], { json: false });
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
formatInlineRecord({
|
||||
id: row.provider,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
}),
|
||||
);
|
||||
for (const warning of row.warnings ?? []) {
|
||||
console.log(pc.yellow(`warning=${warning}`));
|
||||
}
|
||||
const missingConfig = asStringArray(row.details?.missingConfig);
|
||||
if (missingConfig.length > 0) {
|
||||
console.log(pc.dim(`missingConfig=${missingConfig.join(",")}`));
|
||||
}
|
||||
const credentialSource = typeof row.details?.credentialSource === "string"
|
||||
? row.details.credentialSource
|
||||
: null;
|
||||
if (credentialSource) {
|
||||
console.log(pc.dim(`credentialSource=${credentialSource}`));
|
||||
}
|
||||
const detectedCredentialSources = asStringArray(row.details?.detectedCredentialSources);
|
||||
if (detectedCredentialSources.length > 0) {
|
||||
console.log(pc.dim(`detectedCredentialSources=${detectedCredentialSources.join(",")}`));
|
||||
}
|
||||
for (const guidance of row.backupGuidance ?? []) {
|
||||
console.log(pc.dim(`backup=${guidance}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<void> {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const companyId = ctx.companyId!;
|
||||
const agents = (await ctx.api.get<Agent[]>(`/api/companies/${companyId}/agents`)) ?? [];
|
||||
const secrets = (await ctx.api.get<CompanySecret[]>(`/api/companies/${companyId}/secrets`)) ?? [];
|
||||
const candidates = collectInlineSecretMigrationCandidates(agents, secrets);
|
||||
|
||||
if (!opts.apply) {
|
||||
printOutput(
|
||||
{
|
||||
apply: false,
|
||||
agentsToUpdate: new Set(candidates.map((candidate) => candidate.agentId)).size,
|
||||
secretsToCreate: candidates.filter((candidate) => !candidate.existingSecretId).length,
|
||||
secretsToRotate: candidates.filter((candidate) => candidate.existingSecretId).length,
|
||||
candidates,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
if (!ctx.json) {
|
||||
console.log(pc.dim("Re-run with --apply to create/rotate secrets and update agent env bindings."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const createdOrRotated = new Map<string, string>();
|
||||
let createdSecrets = 0;
|
||||
let rotatedSecrets = 0;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const agent = agents.find((row) => row.id === candidate.agentId);
|
||||
const env = asRecord(agent?.adapterConfig.env);
|
||||
const value = env ? toPlainEnvValue(env[candidate.envKey]) : null;
|
||||
if (!value) continue;
|
||||
|
||||
if (candidate.existingSecretId) {
|
||||
await ctx.api.post(`/api/secrets/${candidate.existingSecretId}/rotate`, { value });
|
||||
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, candidate.existingSecretId);
|
||||
rotatedSecrets += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${companyId}/secrets`, {
|
||||
name: candidate.secretName,
|
||||
provider: "local_encrypted",
|
||||
value,
|
||||
description: `Migrated from agent ${candidate.agentId} env ${candidate.envKey}`,
|
||||
});
|
||||
if (!created) throw new Error(`Secret create returned no data for ${candidate.secretName}`);
|
||||
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, created.id);
|
||||
createdSecrets += 1;
|
||||
}
|
||||
|
||||
let updatedAgents = 0;
|
||||
for (const agent of agents) {
|
||||
const env = asRecord(agent.adapterConfig.env);
|
||||
if (!env) continue;
|
||||
const secretIdByEnvKey = new Map<string, string>();
|
||||
for (const [key] of Object.entries(env)) {
|
||||
const secretId = createdOrRotated.get(`${agent.id}:${key}`);
|
||||
if (secretId) secretIdByEnvKey.set(key, secretId);
|
||||
}
|
||||
if (secretIdByEnvKey.size === 0) continue;
|
||||
const adapterConfig = {
|
||||
...agent.adapterConfig,
|
||||
env: buildMigratedAgentEnv(env, secretIdByEnvKey),
|
||||
};
|
||||
await ctx.api.patch(`/api/agents/${agent.id}`, {
|
||||
adapterConfig,
|
||||
replaceAdapterConfig: true,
|
||||
});
|
||||
updatedAgents += 1;
|
||||
}
|
||||
|
||||
printOutput(
|
||||
{
|
||||
apply: true,
|
||||
updatedAgents,
|
||||
createdSecrets,
|
||||
rotatedSecrets,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
}
|
||||
|
||||
export function registerSecretCommands(program: Command): void {
|
||||
const secrets = program.command("secrets").description("Secret declaration and provider operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("list")
|
||||
.description("List secret metadata for a company")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: SecretListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const rows = (await ctx.api.get<CompanySecret[]>(`/api/companies/${ctx.companyId}/secrets`)) ?? [];
|
||||
printOutput(ctx.json ? rows : rows.map(renderSecret), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("declarations")
|
||||
.description("List portable env declarations emitted by company export")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents,projects")
|
||||
.option("--kind <kind>", "Filter declarations: all | secret | plain", "all")
|
||||
.action(async (opts: SecretDeclarationsOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const kind = opts.kind ?? "all";
|
||||
if (!["all", "secret", "plain"].includes(kind)) {
|
||||
throw new Error("Invalid --kind value. Use: all, secret, plain");
|
||||
}
|
||||
const preview = await ctx.api.post<CompanyPortabilityExportPreviewResult>(
|
||||
`/api/companies/${ctx.companyId}/exports/preview`,
|
||||
{ include: parseSecretsInclude(opts.include) },
|
||||
);
|
||||
const declarations = (preview?.manifest.envInputs ?? [])
|
||||
.filter((entry) => kind === "all" || entry.kind === kind);
|
||||
printOutput(ctx.json ? declarations : declarations.map(renderDeclaration), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("create")
|
||||
.description("Create a Paperclip-managed secret")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.requiredOption("--name <name>", "Secret display name")
|
||||
.option("--key <key>", "Portable secret key")
|
||||
.option("--provider <provider>", "Secret provider id")
|
||||
.option("--value <value>", "Secret value")
|
||||
.option("--value-env <name>", "Read secret value from an environment variable")
|
||||
.option("--description <text>", "Description")
|
||||
.action(async (opts: SecretCreateOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
||||
name: opts.name,
|
||||
key: opts.key,
|
||||
provider: opts.provider,
|
||||
value: readValueFromOptions(opts),
|
||||
description: opts.description,
|
||||
});
|
||||
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("link")
|
||||
.description("Link an external provider-owned secret without storing its value in Paperclip")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.requiredOption("--name <name>", "Secret display name")
|
||||
.requiredOption("--provider <provider>", "Secret provider id")
|
||||
.requiredOption("--external-ref <ref>", "Provider secret ARN/name/path/reference")
|
||||
.option("--key <key>", "Portable secret key")
|
||||
.option("--provider-version-ref <ref>", "Provider version id or label")
|
||||
.option("--description <text>", "Description")
|
||||
.action(async (opts: SecretLinkOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
||||
name: opts.name,
|
||||
key: opts.key,
|
||||
provider: opts.provider,
|
||||
managedMode: "external_reference",
|
||||
externalRef: opts.externalRef,
|
||||
providerVersionRef: opts.providerVersionRef,
|
||||
description: opts.description,
|
||||
});
|
||||
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("doctor")
|
||||
.description("Run secret provider health checks through the Paperclip API")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: SecretDoctorOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const health = await ctx.api.get<SecretProviderHealthResponse>(
|
||||
`/api/companies/${ctx.companyId}/secret-providers/health`,
|
||||
);
|
||||
printProviderHealth(health?.providers ?? [], ctx.json);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("providers")
|
||||
.description("List configured secret provider descriptors")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: SecretDoctorOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const rows = (await ctx.api.get<SecretProviderDescriptor[]>(
|
||||
`/api/companies/${ctx.companyId}/secret-providers`,
|
||||
)) ?? [];
|
||||
printOutput(rows, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("migrate-inline-env")
|
||||
.description("Migrate inline sensitive agent env values into secret references")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--apply", "Persist changes; default is a dry run", false)
|
||||
.action(async (opts: SecretMigrateInlineEnvOptions) => {
|
||||
try {
|
||||
await migrateInlineEnv(opts);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
ensurePostgresDatabase,
|
||||
formatEmbeddedPostgresError,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
@@ -117,7 +116,6 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
await prepareEmbeddedPostgresNativeRuntime();
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
runDatabaseRestore,
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
formatEmbeddedPostgresError,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
@@ -1060,7 +1059,6 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
await prepareEmbeddedPostgresNativeRuntime();
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
@@ -1387,12 +1385,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
}
|
||||
|
||||
if (opts.force) {
|
||||
// Only remove the specific files we're about to rewrite, not the whole
|
||||
// repoConfigDir — that directory can contain sibling state such as
|
||||
// <repo>/.paperclip/worktrees/ holding every repo-managed worktree
|
||||
// checkout, and a recursive rmSync here would nuke them all.
|
||||
rmSync(paths.configPath, { force: true });
|
||||
rmSync(paths.envPath, { force: true });
|
||||
rmSync(paths.repoConfigDir, { recursive: true, force: true });
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir as resolveSharedDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir as resolveSharedDefaultLogsDir,
|
||||
resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir as resolveSharedDefaultStorageDir,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot as resolveSharedPaperclipInstanceRoot,
|
||||
} from "@paperclipai/shared/home-paths";
|
||||
|
||||
export {
|
||||
expandHomePrefix,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
};
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(override?: string): string {
|
||||
const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(
|
||||
`Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRoot(instanceId?: string): string {
|
||||
return resolveSharedPaperclipInstanceRoot({ instanceId });
|
||||
const id = resolvePaperclipInstanceId(instanceId);
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", id);
|
||||
}
|
||||
|
||||
export function resolveDefaultConfigPath(instanceId?: string): string {
|
||||
return resolvePaperclipConfigPathForInstance({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultContextPath(): string {
|
||||
@@ -37,23 +38,29 @@ export function resolveDefaultCliAuthPath(): string {
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultEmbeddedPostgresDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultLogsDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs");
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
|
||||
return resolveSharedDefaultSecretsKeyFilePath({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultStorageDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultBackupDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function describeLocalInstancePaths(instanceId?: string) {
|
||||
|
||||
@@ -18,9 +18,6 @@ import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { registerRoutineCommands } from "./commands/routines.js";
|
||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||
import { registerSecretCommands } from "./commands/client/secrets.js";
|
||||
import { registerCloudCommands } from "./commands/client/cloud.js";
|
||||
import { registerSkillsCommands } from "./commands/client/skills.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
@@ -150,9 +147,6 @@ registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerSecretCommands(program);
|
||||
registerCloudCommands(program);
|
||||
registerSkillsCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
||||
{
|
||||
value: "aws_secrets_manager" as const,
|
||||
label: "AWS Secrets Manager",
|
||||
hint: "requires runtime AWS credentials and provider env config",
|
||||
hint: "requires external adapter integration",
|
||||
},
|
||||
{
|
||||
value: "gcp_secret_manager" as const,
|
||||
@@ -84,9 +84,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
||||
|
||||
if (provider !== "local_encrypted") {
|
||||
p.note(
|
||||
provider === "aws_secrets_manager"
|
||||
? "AWS credentials must come from the Paperclip server runtime (IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, or short-lived shell env), not from Paperclip company secrets."
|
||||
: `${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
||||
`${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
||||
"Heads up",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
|
||||
"include": ["src", "../packages/shared/src"]
|
||||
}
|
||||
|
||||
@@ -143,150 +143,6 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
## Skills Commands
|
||||
|
||||
`paperclipai skills` covers three distinct operations:
|
||||
|
||||
1. **Company install** — adds or updates a row in `company_skills` for the
|
||||
whole company. This is what `skills install`, `skills import`, `skills create`,
|
||||
and `skills scan-projects` do.
|
||||
2. **Agent attach** — replaces an agent's *desired* company skill set
|
||||
(`skills agent sync`/`clear`). This is a desired-state operation on the
|
||||
agent's adapter config; it does not change the company library.
|
||||
3. **Adapter runtime sync** — the adapter reconciles the desired skill set
|
||||
with files on disk and reports an `AgentSkillSnapshot` (`skills agent list`).
|
||||
`skills agent sync` triggers this automatically after updating desired state.
|
||||
|
||||
Required Paperclip runtime skills (heartbeat, etc.) remain server-enforced and
|
||||
are added on top of whatever the desired set names.
|
||||
|
||||
### Catalog (app-shipped skills)
|
||||
|
||||
The Paperclip app ships a curated catalog under `@paperclipai/skills-catalog`.
|
||||
Browse and inspect commands never mutate company state; `install` adds a catalog
|
||||
skill to the company library.
|
||||
|
||||
```sh
|
||||
pnpm paperclipai skills browse [--kind bundled|optional] [--category <slug>] [--query <text>]
|
||||
pnpm paperclipai skills search "<text>" [--kind bundled|optional] [--category <slug>]
|
||||
pnpm paperclipai skills inspect <catalog-id-or-key-or-slug>
|
||||
pnpm paperclipai skills install <catalog-id-or-key-or-slug> [--as <slug>] [--force] --company-id <company-id>
|
||||
```
|
||||
|
||||
Catalog semantics:
|
||||
|
||||
- **Bundled** skills live in `packages/skills-catalog/catalog/bundled/<category>/<slug>`
|
||||
and are recommended defaults for most companies. They use canonical key
|
||||
`paperclipai/bundled/<category>/<slug>`.
|
||||
- **Optional** skills live in `packages/skills-catalog/catalog/optional/<category>/<slug>`
|
||||
and are role-specific or domain-specific (browser, AWS ops, etc.). Same key
|
||||
shape with `optional` in place of `bundled`.
|
||||
- `skills install` materializes the catalog files into a company-managed skill
|
||||
directory and records provenance (`catalogId`, `catalogKey`, `packageVersion`,
|
||||
`originHash`, …) so future updates and audit decisions stay consistent.
|
||||
- `--as <slug>` overrides the company skill slug. `--force` may replace a
|
||||
same-key catalog-managed skill but never bypasses hard validation or hard-stop
|
||||
audit findings.
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai skills browse --kind bundled --company-id <company-id>
|
||||
pnpm paperclipai skills search "pull request" --kind bundled
|
||||
pnpm paperclipai skills inspect github-pr-workflow
|
||||
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
|
||||
pnpm paperclipai skills install paperclipai:optional:browser:agent-browser --company-id <company-id>
|
||||
```
|
||||
|
||||
External GitHub, skills.sh, local-path, and URL sources still go through
|
||||
`skills import`; catalog commands are for the app-shipped catalog only.
|
||||
|
||||
### Company library
|
||||
|
||||
```sh
|
||||
pnpm paperclipai skills list --company-id <company-id>
|
||||
pnpm paperclipai skills show <skill-id-or-key-or-slug> --company-id <company-id>
|
||||
pnpm paperclipai skills file <skill-id-or-key-or-slug> [--path SKILL.md] --company-id <company-id>
|
||||
pnpm paperclipai skills import <source> --company-id <company-id>
|
||||
pnpm paperclipai skills create --name "Review PRs" [--slug review-prs] [--description "..."] [--body-file SKILL.md] --company-id <company-id>
|
||||
pnpm paperclipai skills scan-projects [--project-id <id>...] [--workspace-id <id>...] --company-id <company-id>
|
||||
pnpm paperclipai skills check [skill-id-or-key-or-slug] --company-id <company-id>
|
||||
pnpm paperclipai skills update <skill-id-or-key-or-slug> [--force] --company-id <company-id>
|
||||
pnpm paperclipai skills update --all [--force] --company-id <company-id>
|
||||
pnpm paperclipai skills audit [skill-id-or-key-or-slug] --company-id <company-id>
|
||||
pnpm paperclipai skills reset <skill-id-or-key-or-slug> [--yes] [--force] --company-id <company-id>
|
||||
pnpm paperclipai skills remove <skill-id-or-key-or-slug> --yes --company-id <company-id>
|
||||
```
|
||||
|
||||
`skills import <source>` accepts a skills.sh URL, the equivalent
|
||||
`<owner>/<repo>/<skill>` shorthand, a GitHub URL, a local path, or an
|
||||
`npx skills add …` command. See `references/company-skills.md` in the agent
|
||||
skill bundle for the source-type table.
|
||||
|
||||
`skills check`, `skills update`, `skills audit`, and `skills reset` are the
|
||||
maintenance loop for catalog-installed skills:
|
||||
|
||||
- `check` reports whether each skill's installed bytes match its pinned origin
|
||||
(`hasUpdate`, `installedHash`, `originHash`, `updateHoldReason`,
|
||||
`auditVerdict`).
|
||||
- `update` installs the pinned update through the existing install-update API.
|
||||
`--all` checks every company skill and updates only those with
|
||||
`hasUpdate=true`. `--force` discards local-modification or soft-audit holds;
|
||||
hard-stop audit findings still block the update.
|
||||
- `audit` re-scans installed bytes and reports findings without executing
|
||||
anything.
|
||||
- `reset` reinstalls a catalog-managed skill from its pinned origin, discarding
|
||||
local edits. Prompts in a TTY; requires `--yes` for non-interactive use.
|
||||
|
||||
### Agent attach
|
||||
|
||||
```sh
|
||||
pnpm paperclipai skills agent list <agent-id-or-shortname> --company-id <company-id>
|
||||
pnpm paperclipai skills agent sync <agent-id-or-shortname> --skill <skill-id-or-key-or-slug> [--skill <skill-id-or-key-or-slug>...] --company-id <company-id>
|
||||
pnpm paperclipai skills agent clear <agent-id-or-shortname> --yes --company-id <company-id>
|
||||
```
|
||||
|
||||
`skills agent sync` replaces the agent's non-required desired skill set (it is
|
||||
not additive) and returns the resulting adapter `AgentSkillSnapshot`.
|
||||
`skills agent clear` sends an empty desired list. Required Paperclip skills are
|
||||
still enforced by the server in both cases.
|
||||
|
||||
### Notes
|
||||
|
||||
- Skill references accept company skill `id`, canonical `key`, or unique
|
||||
`slug`; catalog references accept catalog `id`, `key`, or unique `slug`.
|
||||
- `skills file` prints raw file content in human mode so it can be piped.
|
||||
- `skills create --body-file -` reads the skill markdown body from stdin.
|
||||
- `skills remove`, `skills reset`, and `skills agent clear` prompt in a TTY and
|
||||
require `--yes` in non-interactive use.
|
||||
- `--json` prints the raw API result for each command.
|
||||
|
||||
## Secrets Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets list --company-id <company-id>
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> [--include agents,projects] [--kind secret]
|
||||
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> [--apply]
|
||||
```
|
||||
|
||||
Secret listing and declarations never print secret values. `create` accepts
|
||||
`--value-env` so shell history does not capture the value. `link` records
|
||||
provider-owned references without copying the secret value into Paperclip.
|
||||
For AWS-backed secrets, `secrets doctor` reports missing non-secret provider
|
||||
env and the expected AWS SDK runtime credential source; do not store AWS
|
||||
bootstrap credentials in Paperclip secrets.
|
||||
|
||||
Per-company provider vaults (multiple vault instances per provider, default
|
||||
vault selection, coming-soon GCP/Vault) are configured from the board UI under
|
||||
`Company Settings → Secrets → Provider vaults` or through
|
||||
`/api/companies/{companyId}/secret-provider-configs`. There is no CLI surface
|
||||
for vault management today. See the
|
||||
[secrets deploy guide](../docs/deploy/secrets.md#provider-vaults) and
|
||||
[API reference](../docs/api/secrets.md#provider-vaults) for the contract.
|
||||
|
||||
## Approval Commands
|
||||
|
||||
```sh
|
||||
@@ -322,28 +178,7 @@ pnpm paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhos
|
||||
|
||||
## Local Storage Defaults
|
||||
|
||||
Local Paperclip data lives under the selected instance root. `PAPERCLIP_HOME` chooses the home directory and `PAPERCLIP_INSTANCE_ID` chooses the instance.
|
||||
|
||||
```text
|
||||
~/.paperclip/ # PAPERCLIP_HOME
|
||||
└── instances/
|
||||
└── default/ # instance root (PAPERCLIP_INSTANCE_ID)
|
||||
├── config.json # runtime config
|
||||
├── .env # instance env file
|
||||
├── db/ # embedded PostgreSQL data
|
||||
├── data/
|
||||
│ ├── storage/ # local_disk uploads
|
||||
│ └── backups/ # automatic DB backups
|
||||
├── logs/
|
||||
├── secrets/
|
||||
│ └── master.key # local_encrypted master key
|
||||
├── workspaces/ # default agent workspaces
|
||||
├── projects/ # project execution workspaces
|
||||
├── companies/ # per-company adapter homes (e.g. codex-home)
|
||||
└── codex-home/ # per-instance codex home (when not company-scoped)
|
||||
```
|
||||
|
||||
Default paths for the canonical install:
|
||||
Default local instance root is `~/.paperclip/instances/default`:
|
||||
|
||||
- config: `~/.paperclip/instances/default/config.json`
|
||||
- embedded db: `~/.paperclip/instances/default/db`
|
||||
|
||||
@@ -143,32 +143,13 @@ The database mode is controlled by `DATABASE_URL`:
|
||||
|
||||
Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode.
|
||||
|
||||
## Resource membership tables
|
||||
|
||||
Paperclip stores current-user sidebar membership state in:
|
||||
|
||||
- `project_memberships`
|
||||
- `agent_memberships`
|
||||
|
||||
These rows are company-scoped and user-scoped. A missing row means the user is joined, so existing users keep seeing projects and agents in the sidebar until they explicitly leave them. Rows only control sidebar visibility; they do not affect project/agent detail access, all-pages, selectors, assignment flows, or existing company permissions.
|
||||
|
||||
Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes.
|
||||
|
||||
## Plugin database namespaces
|
||||
|
||||
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.
|
||||
|
||||
## Backups
|
||||
|
||||
Paperclip supports automatic and manual logical database backups. These dumps include
|
||||
non-system database schemas such as `public`, the Drizzle migration journal, and
|
||||
plugin-owned database schemas. See `doc/DEVELOPING.md` for the current
|
||||
`paperclipai db:backup` / `pnpm db:backup` commands and backup retention
|
||||
configuration.
|
||||
|
||||
Database backups do not include non-database instance files such as local-disk
|
||||
uploads, workspace files, or the local encrypted secrets master key. Back those paths
|
||||
up separately when you need full instance disaster recovery.
|
||||
Paperclip supports automatic and manual database backups. See `doc/DEVELOPING.md` for the current `paperclipai db:backup` / `pnpm db:backup` commands and backup retention configuration.
|
||||
|
||||
## Secret storage
|
||||
|
||||
@@ -176,18 +157,12 @@ Paperclip stores secret metadata and versions in:
|
||||
|
||||
- `company_secrets`
|
||||
- `company_secret_versions`
|
||||
- `company_secret_bindings`
|
||||
- `secret_access_events`
|
||||
|
||||
Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`.
|
||||
|
||||
For local/default installs, the active provider is `local_encrypted`:
|
||||
|
||||
- Secret material is encrypted at rest with a local master key.
|
||||
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
||||
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
|
||||
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
|
||||
|
||||
Optional overrides:
|
||||
|
||||
@@ -209,10 +184,5 @@ pnpm paperclipai configure --section secrets
|
||||
Inline secret migration command:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# direct database maintenance fallback
|
||||
pnpm secrets:migrate-inline-env --apply
|
||||
```
|
||||
|
||||
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
||||
|
||||
@@ -125,50 +125,19 @@ When running `authenticated` mode, if the only instance admin is `local-board`,
|
||||
|
||||
This prevents lockout when a user migrates from long-running local trusted usage to authenticated mode.
|
||||
|
||||
## 8. First Admin Setup For Fresh Authenticated Installs
|
||||
|
||||
Fresh authenticated installs start in `bootstrap_pending` until the first
|
||||
`instance_admin` exists.
|
||||
|
||||
For `authenticated/private`, Paperclip supports a browser-first setup path:
|
||||
|
||||
1. open the Paperclip URL from the private network or appliance UI
|
||||
2. sign in or create a Paperclip account
|
||||
3. choose `Claim this instance` on the setup screen
|
||||
|
||||
That browser claim promotes the signed-in session user to the first instance
|
||||
admin and then falls through to normal onboarding. The endpoint is available
|
||||
only to real browser session actors in `authenticated/private`; unauthenticated
|
||||
requests, agent keys, board API keys, and local implicit board actors are
|
||||
rejected.
|
||||
|
||||
The CLI fallback remains supported in all authenticated setup states:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai auth bootstrap-ceo
|
||||
```
|
||||
|
||||
That command prints a one-time first-admin invite URL. Browser claim and
|
||||
bootstrap invite acceptance share the same first-admin transaction, so whichever
|
||||
path wins first makes later attempts return a conflict.
|
||||
|
||||
For `authenticated/public`, browser first-admin claim is intentionally disabled.
|
||||
Public deployments must use the high-entropy bootstrap invite path unless a
|
||||
future public-hosted setup design explicitly changes this policy.
|
||||
|
||||
## 9. Current Code Reality (As Of 2026-02-23)
|
||||
## 8. Current Code Reality (As Of 2026-02-23)
|
||||
|
||||
- runtime values are `local_trusted | authenticated`
|
||||
- `authenticated` uses Better Auth sessions and bootstrap invite flow
|
||||
- `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access
|
||||
- company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent
|
||||
|
||||
## 10. Naming and Compatibility Policy
|
||||
## 9. Naming and Compatibility Policy
|
||||
|
||||
- canonical naming is `local_trusted` and `authenticated` with `private/public` exposure
|
||||
- no long-term compatibility alias layer for discarded naming variants
|
||||
|
||||
## 11. Relationship to Other Docs
|
||||
## 10. Relationship to Other Docs
|
||||
|
||||
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
|
||||
- V1 contract: `doc/SPEC-implementation.md`
|
||||
|
||||
@@ -72,13 +72,6 @@ pnpm dev --bind lan
|
||||
```
|
||||
|
||||
This runs dev as `authenticated/private` with a private-network bind preset.
|
||||
On a fresh authenticated/private instance, open the app, sign in or create an
|
||||
account, and use the setup screen to claim the first instance admin from the
|
||||
browser. The CLI fallback remains:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai auth bootstrap-ceo
|
||||
```
|
||||
|
||||
For Tailscale-only reachability on a detected tailnet address:
|
||||
|
||||
@@ -164,27 +157,6 @@ See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`)
|
||||
|
||||
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
|
||||
## Local Instance Layout
|
||||
|
||||
Every local install keeps runtime state directly under the selected instance root:
|
||||
|
||||
```text
|
||||
~/.paperclip/instances/default/ # instance root
|
||||
config.json # runtime config
|
||||
.env # instance env file
|
||||
db/ # embedded PostgreSQL data
|
||||
data/
|
||||
storage/ # local_disk uploads
|
||||
backups/ # automatic DB backups
|
||||
logs/
|
||||
secrets/master.key # local_encrypted master key
|
||||
workspaces/<agent-id>/ # default agent workspaces
|
||||
projects/ # project execution workspaces
|
||||
companies/<company-id>/codex-home/ # per-company codex_local home
|
||||
```
|
||||
|
||||
`PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` override the home root and instance id respectively. `paperclipai onboard` echoes the resolved values in its banner (`Local home: <home> | instance: <id> | config: <path>`) so you can confirm where state will land before continuing.
|
||||
|
||||
## Database in Dev (Auto-Handled)
|
||||
|
||||
For local development, leave `DATABASE_URL` unset.
|
||||
@@ -192,7 +164,7 @@ The server will automatically use embedded PostgreSQL and persist data at:
|
||||
|
||||
- `~/.paperclip/instances/default/db`
|
||||
|
||||
Override home or instance:
|
||||
Override home and instance:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
|
||||
@@ -308,7 +280,7 @@ paperclipai worktree init --from-data-dir ~/.paperclip
|
||||
paperclipai worktree init --force
|
||||
```
|
||||
|
||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install. Point `--from-config` at the instance config:
|
||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
|
||||
|
||||
```sh
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
@@ -420,62 +392,6 @@ eval "$(pnpm paperclipai worktree env)"
|
||||
|
||||
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
|
||||
|
||||
## App-Shipped Skills Catalog
|
||||
|
||||
The Paperclip app ships a curated catalog of company skills out of the box. The
|
||||
catalog is a workspace package at `packages/skills-catalog`:
|
||||
|
||||
```text
|
||||
packages/skills-catalog/
|
||||
catalog/
|
||||
bundled/<category>/<slug>/SKILL.md # recommended defaults
|
||||
optional/<category>/<slug>/SKILL.md # role/domain-specific
|
||||
generated/catalog.json # checked-in manifest
|
||||
scripts/
|
||||
build-catalog-manifest.ts # regenerate generated/catalog.json
|
||||
validate-catalog.ts # validation only
|
||||
src/ # builder + types consumed by server/CLI
|
||||
```
|
||||
|
||||
Server and CLI import the generated manifest; they do not crawl repository
|
||||
paths at request time. Root `skills/` remains reserved for Paperclip runtime
|
||||
skills and is not part of the catalog.
|
||||
|
||||
Validate the catalog without writing the manifest:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/skills-catalog validate
|
||||
```
|
||||
|
||||
Regenerate `generated/catalog.json` after editing any catalog `SKILL.md`,
|
||||
frontmatter, file inventory, category, or slug:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/skills-catalog build:manifest
|
||||
```
|
||||
|
||||
The package's `build` script runs `build:manifest` and then `tsc`; tests live
|
||||
under `pnpm --filter @paperclipai/skills-catalog test`. Validation fails when:
|
||||
|
||||
- a catalog entry is not under `catalog/bundled/<category>/<slug>` or
|
||||
`catalog/optional/<category>/<slug>`
|
||||
- `SKILL.md` is missing or the frontmatter `name`/`description` is empty
|
||||
- the frontmatter `key` disagrees with the generated canonical key
|
||||
- two catalog entries share an `id`, `key`, or `slug`
|
||||
- file inventory contains absolute paths, `..`, broken symlinks, or files
|
||||
outside the skill directory
|
||||
- the regenerated manifest differs from the checked-in
|
||||
`generated/catalog.json`
|
||||
|
||||
Trust level is derived from inventory: `markdown_only` (markdown + references
|
||||
only), `assets` (other non-script files), or `scripts_executables` (any
|
||||
executable script). The build contract is documented in
|
||||
`doc/plans/2026-05-26-skills-cli-catalog-contract.md`.
|
||||
|
||||
CI runs `pnpm --filter @paperclipai/skills-catalog validate` and the package's
|
||||
vitest suite, so always regenerate the manifest in the same commit as the
|
||||
catalog change.
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
In another terminal:
|
||||
@@ -505,9 +421,7 @@ If you set `DATABASE_URL`, the server will use that instead of embedded PostgreS
|
||||
|
||||
## Automatic DB Backups
|
||||
|
||||
Paperclip can run automatic logical database backups on a timer. These backups cover
|
||||
non-system database schemas, including migration history and plugin-owned database
|
||||
schemas. Defaults:
|
||||
Paperclip can run automatic DB backups on a timer. Defaults:
|
||||
|
||||
- enabled
|
||||
- every 60 minutes
|
||||
@@ -535,10 +449,6 @@ Environment overrides:
|
||||
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
|
||||
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
|
||||
|
||||
DB backups are not full instance filesystem backups. For full local disaster
|
||||
recovery, also back up local storage files and the local encrypted secrets key if
|
||||
those providers are enabled.
|
||||
|
||||
## Secrets in Dev
|
||||
|
||||
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
||||
@@ -546,7 +456,6 @@ Agent env vars now support secret references. By default, secret values are stor
|
||||
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
||||
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
||||
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
||||
- Back up the key file and database together; either one alone is not enough to restore local encrypted secrets.
|
||||
|
||||
Strict mode (recommended outside local trusted machines):
|
||||
|
||||
@@ -555,20 +464,12 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||
```
|
||||
|
||||
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
||||
Authenticated deployments default strict mode on unless explicitly overridden.
|
||||
|
||||
CLI configuration support:
|
||||
|
||||
- `pnpm paperclipai onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
||||
- `pnpm paperclipai configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
||||
- `pnpm paperclipai doctor` validates secrets adapter configuration, can create a missing local key file with `--repair`, and reports missing AWS Secrets Manager bootstrap env when that provider is selected.
|
||||
- Provider health is available at `GET /api/companies/:companyId/secret-providers/health` and reports local key permission warnings plus backup guidance.
|
||||
|
||||
Per-company provider vaults are configured in the board UI under
|
||||
`Company Settings → Secrets → Provider vaults`, backed by
|
||||
`/api/companies/{companyId}/secret-provider-configs`. The CLI does not own
|
||||
vault lifecycle today. See `docs/deploy/secrets.md` (`Provider Vaults` section)
|
||||
for the operator model.
|
||||
- `pnpm paperclipai doctor` validates secrets adapter configuration and can create a missing local key file with `--repair`.
|
||||
|
||||
Migration helper for existing inline env secrets:
|
||||
|
||||
@@ -617,12 +518,10 @@ pnpm paperclipai dashboard get
|
||||
|
||||
See full command reference in `doc/CLI.md`.
|
||||
|
||||
## Agent Invite Onboarding Endpoints
|
||||
## OpenClaw Invite Onboarding Endpoints
|
||||
|
||||
Agent-oriented invite onboarding now exposes machine-readable API docs:
|
||||
|
||||
The board UI generates agent onboarding prompts from the add-agent modal (`+` in the agent sidebar), so agent onboarding sits with the rest of agent creation rather than company member invite settings.
|
||||
|
||||
- `GET /api/invites/:token` returns invite summary plus onboarding and skills index links.
|
||||
- `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints).
|
||||
- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates.
|
||||
@@ -640,7 +539,7 @@ pnpm smoke:openclaw-join
|
||||
What it validates:
|
||||
|
||||
- invite creation for agent-only join
|
||||
- agent join request using `adapterType=openclaw_gateway`
|
||||
- agent join request using `adapterType=openclaw`
|
||||
- board approval + one-time API key claim semantics
|
||||
- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver
|
||||
|
||||
|
||||
@@ -117,16 +117,6 @@ services:
|
||||
- bootstrap invite URL defaults
|
||||
- hostname allowlist defaults (hostname extracted from URL)
|
||||
|
||||
For fresh `authenticated/private` Docker or appliance-style installs, the first
|
||||
admin can now be claimed entirely from the browser after sign-in. Open the
|
||||
Paperclip URL, sign in or create an account, then choose `Claim this instance`
|
||||
on the setup screen. This browser claim is disabled for `authenticated/public`;
|
||||
public deployments should run the high-entropy CLI invite fallback instead:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai auth bootstrap-ceo
|
||||
```
|
||||
|
||||
Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`).
|
||||
|
||||
Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).
|
||||
|
||||
@@ -118,7 +118,6 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
||||
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
|
||||
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
|
||||
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
|
||||
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
|
||||
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
|
||||
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
|
||||
|
||||
|
||||
@@ -143,13 +143,6 @@ This keeps the default install path unchanged while allowing explicit installs w
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
The release script now verifies two things after a canary publish:
|
||||
|
||||
- the `canary` dist-tag resolves to the version that was just published
|
||||
- every published internal `@paperclipai/*` dependency referenced by that manifest exists on npm
|
||||
|
||||
It also treats `latest -> canary` as a failure by default, because npm metadata can otherwise leave the default install path pointing at an unreleased canary dependency graph. Only pass `./scripts/release.sh canary --allow-canary-latest` when that `latest` behavior is explicitly intended.
|
||||
|
||||
### Stable
|
||||
|
||||
Stable publishes use the npm dist-tag `latest`.
|
||||
@@ -176,58 +169,6 @@ That means:
|
||||
|
||||
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
|
||||
|
||||
## Release enrollment for new public packages
|
||||
|
||||
Paperclip does not auto-publish every non-private workspace package anymore.
|
||||
CI publishing is controlled by [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json).
|
||||
|
||||
When you add a new public package:
|
||||
|
||||
1. add it to the manifest and decide whether CI should publish it immediately
|
||||
2. if CI should publish it, bootstrap the package on npm before merge
|
||||
3. if CI should not publish it yet, keep `"publishFromCi": false`
|
||||
4. only enable `"publishFromCi": true` after npm trusted publishing is configured for that package
|
||||
|
||||
PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`.
|
||||
|
||||
### One-time bootstrap sequence for a new package
|
||||
|
||||
The first publish of a brand-new package still needs one human maintainer with npm write access.
|
||||
After that, trusted publishing can take over.
|
||||
|
||||
Example for `@paperclipai/adapter-acpx-local` from the repo root:
|
||||
|
||||
```bash
|
||||
# safe preview
|
||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local
|
||||
|
||||
# one-time first publish from an authenticated maintainer machine
|
||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456
|
||||
```
|
||||
|
||||
The helper script:
|
||||
|
||||
- checks that the package does not already exist on npm
|
||||
- builds the target package unless `--skip-build` is passed
|
||||
- runs `npm pack --dry-run` in the package directory
|
||||
- only runs the real `npm publish --access public` when `--publish --otp <code>` is provided
|
||||
|
||||
For the real `--publish` step, the maintainer machine must already be authenticated to npm.
|
||||
If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper.
|
||||
That local human auth is fine for the one-time bootstrap publish; we just do not want the same auth model inside CI.
|
||||
The helper now requires `--otp <code>` up front for `--publish`, so it fails before the real publish attempt if the one-time password is missing.
|
||||
|
||||
After that first publish succeeds:
|
||||
|
||||
1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local`
|
||||
2. go to `Settings` → `Trusted publishing`
|
||||
3. add repository `paperclipai/paperclip`
|
||||
4. set workflow filename to `release.yml`
|
||||
5. optionally go to `Settings` → `Publishing access` and enable `Require two-factor authentication and disallow tokens`
|
||||
6. keep `publishFromCi: true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
||||
|
||||
Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step is only the first package creation on npm.
|
||||
|
||||
## Rollback model
|
||||
|
||||
Rollback does not unpublish anything.
|
||||
|
||||
@@ -67,27 +67,6 @@ Why:
|
||||
- the single `release.yml` workflow handles both canary and stable publishing
|
||||
- GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side
|
||||
|
||||
### 2.2.1. Newly added public packages need a bootstrap phase
|
||||
|
||||
Trusted publishing is configured on the npm package itself, not at the repo scope.
|
||||
That means a brand-new public package must not be auto-enrolled into CI publishing until its npm package exists and its trusted publisher has been configured.
|
||||
|
||||
Repo policy:
|
||||
|
||||
1. add every non-private package to [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
||||
2. set `"publishFromCi": true` only when CI is expected to publish that package
|
||||
3. if the package is not ready for CI publishing yet, keep `"publishFromCi": false`
|
||||
4. complete the package bootstrap before merging any PR that changes a release-enabled new package
|
||||
|
||||
Bootstrap sequence for a new package:
|
||||
|
||||
1. publish the package once from a trusted maintainer machine using normal npm auth
|
||||
2. open that package on npm and add the `paperclipai/paperclip` trusted publisher for `.github/workflows/release.yml`
|
||||
3. rerun or dry-run the release flow as needed to confirm CI publishing now works
|
||||
4. only then enable `"publishFromCi": true`
|
||||
|
||||
PR CI enforces this by checking changed release-enabled package manifests against npm. That keeps `master` canary publishing healthy while preserving the no-long-lived-token model for normal CI releases.
|
||||
|
||||
### 2.3. Verify trusted publishing before removing old auth
|
||||
|
||||
After the workflows are live:
|
||||
|
||||
@@ -63,8 +63,6 @@ It:
|
||||
- verifies the pushed commit
|
||||
- computes the canary version for the current UTC date
|
||||
- publishes under npm dist-tag `canary`
|
||||
- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm
|
||||
- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional
|
||||
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
|
||||
|
||||
Users install canaries with:
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
# AWS Secrets Manager Provider
|
||||
|
||||
Operational contract for the hosted `aws_secrets_manager` secret provider used by Paperclip Cloud.
|
||||
|
||||
## Scope
|
||||
|
||||
- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS.
|
||||
- Source of truth for secret values is AWS Secrets Manager, not Postgres.
|
||||
- Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution.
|
||||
- AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets.
|
||||
- Remote import for existing AWS secrets is metadata-only. Preview/import uses
|
||||
AWS inventory metadata and creates Paperclip external references; it does not
|
||||
copy plaintext into Paperclip.
|
||||
- Per-company AWS provider vaults (named instances of `aws_secrets_manager`
|
||||
with their own region, namespace, prefix, KMS key id, and tags) are managed
|
||||
in the board UI under `Company Settings → Secrets → Provider vaults`. See
|
||||
[Provider Vaults](../docs/deploy/secrets.md#provider-vaults) for the operator
|
||||
model and [Provider Vaults API](../docs/api/secrets.md#provider-vaults) for
|
||||
the routes. The bootstrap trust model in this document still applies — vault
|
||||
config carries non-sensitive routing metadata only, never AWS credentials.
|
||||
|
||||
## Bootstrap Trust Model
|
||||
|
||||
The AWS provider has a chicken-and-egg boundary: Paperclip cannot use
|
||||
`company_secrets` to unlock the AWS provider that stores those secrets. The
|
||||
initial AWS trust must exist before the Paperclip server starts.
|
||||
|
||||
Allowed bootstrap locations:
|
||||
|
||||
- Infrastructure IAM or workload identity attached to the Paperclip server
|
||||
runtime.
|
||||
- Process environment or orchestrator secret store used to start the Paperclip
|
||||
server.
|
||||
- Local AWS SDK sources such as `AWS_PROFILE`, AWS SSO/shared config, web
|
||||
identity, container metadata, or instance metadata.
|
||||
- Short-lived shell credentials for local development only.
|
||||
|
||||
Do not ask operators to paste AWS root credentials or long-lived IAM user access
|
||||
keys into the Paperclip board UI. Do not store those bootstrap keys in
|
||||
`company_secrets`.
|
||||
|
||||
## Paperclip Cloud Bootstrap
|
||||
|
||||
Paperclip Cloud must provision the AWS backing resources before any board user
|
||||
can create AWS-backed company secrets:
|
||||
|
||||
1. Create or select the deployment KMS key.
|
||||
2. Create the Paperclip server runtime role for the deployment.
|
||||
3. Attach a minimum IAM policy scoped to the deployment Secrets Manager prefix
|
||||
and the configured KMS key.
|
||||
4. Configure the server runtime with the non-secret provider environment
|
||||
variables below.
|
||||
5. Run `paperclipai doctor` or the provider health endpoint from the deployed
|
||||
runtime and confirm that the provider reports the expected region, prefix,
|
||||
deployment id, KMS setting, and AWS SDK credential source.
|
||||
|
||||
Once this is in place, the board UI can create Paperclip-managed AWS secrets and
|
||||
Paperclip will write them under the deployment/company namespace.
|
||||
|
||||
## Self-Hosted And Local Bootstrap
|
||||
|
||||
Self-hosted AWS deployments should use the AWS SDK default credential provider
|
||||
chain. Preferred sources are role-based:
|
||||
|
||||
- EC2 instance profile.
|
||||
- ECS task role.
|
||||
- EKS IRSA or another OIDC web identity role.
|
||||
- AWS SSO/shared config via `AWS_PROFILE`.
|
||||
|
||||
Local development can use:
|
||||
|
||||
```sh
|
||||
aws sso login --profile paperclip-dev
|
||||
AWS_PROFILE=paperclip-dev \
|
||||
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager \
|
||||
PAPERCLIP_SECRETS_AWS_REGION=us-east-1 \
|
||||
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=dev-local \
|
||||
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-... \
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Temporary `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` environment credentials
|
||||
are acceptable only as a local break-glass or short-lived test source. They
|
||||
should not be written to Paperclip config, committed to `.env` files, stored in
|
||||
`company_secrets`, or used as the default Paperclip Cloud bootstrap path.
|
||||
|
||||
## Deployment Config
|
||||
|
||||
Required environment variables:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager
|
||||
PAPERCLIP_SECRETS_AWS_REGION=us-east-1
|
||||
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=prod-us-1
|
||||
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-...
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_SECRETS_AWS_PREFIX=paperclip
|
||||
PAPERCLIP_SECRETS_AWS_ENVIRONMENT=production
|
||||
PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER=paperclip
|
||||
PAPERCLIP_SECRETS_AWS_ENDPOINT=
|
||||
PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS=30
|
||||
```
|
||||
|
||||
Naming convention for Paperclip-managed secrets:
|
||||
|
||||
```text
|
||||
paperclip/{deploymentId}/{companyId}/{secretKey}
|
||||
```
|
||||
|
||||
Tag set for Paperclip-managed secrets:
|
||||
|
||||
- `paperclip:managed-by=paperclip`
|
||||
- `paperclip:provider-owner=<owner tag>`
|
||||
- `paperclip:deployment-id=<deployment id>`
|
||||
- `paperclip:company-id=<company id>`
|
||||
- `paperclip:secret-key=<secret key>`
|
||||
- `paperclip:environment=<environment tag>`
|
||||
|
||||
## IAM And KMS Assumptions
|
||||
|
||||
Launch posture:
|
||||
|
||||
- One Paperclip app role per deployment.
|
||||
- One deployment-scoped KMS key per deployment at launch.
|
||||
- Future per-company KMS keys remain compatible because Paperclip stores provider refs and version metadata separately from values.
|
||||
|
||||
Minimum IAM boundary:
|
||||
|
||||
- Allow `secretsmanager:CreateSecret`, `PutSecretValue`, `GetSecretValue`, and `DeleteSecret`.
|
||||
- Scope resources to the deployment prefix:
|
||||
|
||||
```text
|
||||
arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*
|
||||
```
|
||||
|
||||
- Allow `kms:Encrypt`, `kms:Decrypt`, `kms:GenerateDataKey`, and `kms:DescribeKey` for the configured deployment CMK.
|
||||
- Deny wildcard access outside the deployment prefix.
|
||||
- Prefer workload identity / role-based auth. Do not store AWS credentials inline in Paperclip config.
|
||||
|
||||
Example minimum policy shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PaperclipDeploymentSecrets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"secretsmanager:CreateSecret",
|
||||
"secretsmanager:PutSecretValue",
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DeleteSecret"
|
||||
],
|
||||
"Resource": "arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*"
|
||||
},
|
||||
{
|
||||
"Sid": "PaperclipDeploymentKms",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey",
|
||||
"kms:DescribeKey"
|
||||
],
|
||||
"Resource": "arn:aws:kms:<region>:<account-id>:key/<key-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Operational expectation:
|
||||
|
||||
- Paperclip-managed secrets may be deleted only by Paperclip or an operator with equivalent break-glass access.
|
||||
- External references may resolve through Paperclip runtime, but Paperclip should not delete the external secret resource.
|
||||
|
||||
## Remote Import Inventory IAM
|
||||
|
||||
Remote import preview needs one additional AWS permission:
|
||||
|
||||
```json
|
||||
{
|
||||
"Sid": "PaperclipRemoteSecretInventory",
|
||||
"Effect": "Allow",
|
||||
"Action": "secretsmanager:ListSecrets",
|
||||
"Resource": "*"
|
||||
}
|
||||
```
|
||||
|
||||
This is intentionally separate from the managed create/rotate/delete policy.
|
||||
AWS treats `ListSecrets` as an account/Region inventory action; do not document
|
||||
secret ARNs, names, tags, or AWS request filters as an IAM boundary for it. Use
|
||||
`Resource: "*"` and decide whether inventory exposure is acceptable for the AWS
|
||||
account and Region behind each provider vault.
|
||||
|
||||
Remote import preview/import must not call:
|
||||
|
||||
- `secretsmanager:GetSecretValue`
|
||||
- `secretsmanager:BatchGetSecretValue`
|
||||
- `kms:Decrypt`
|
||||
|
||||
Those permissions are only needed later when a bound runtime resolves an
|
||||
imported external reference. For imported refs, scope read permissions to the
|
||||
operator-approved external prefixes that Paperclip is allowed to consume:
|
||||
|
||||
```json
|
||||
{
|
||||
"Sid": "PaperclipResolveImportedExternalReferences",
|
||||
"Effect": "Allow",
|
||||
"Action": "secretsmanager:GetSecretValue",
|
||||
"Resource": [
|
||||
"arn:aws:secretsmanager:<region>:<account-id>:secret:<approved-external-prefix>/*"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If selected external secrets use customer-managed KMS keys, also grant
|
||||
`kms:Decrypt` and `kms:DescribeKey` on those keys. Keep managed write/delete
|
||||
permissions scoped to `paperclip/<deployment-id>/*`; do not broaden them for
|
||||
remote import.
|
||||
|
||||
Safe scoping guidance:
|
||||
|
||||
- Prefer one Paperclip runtime role per environment/account.
|
||||
- Point provider vaults at the intended AWS account and Region instead of a
|
||||
broad central admin role.
|
||||
- Enable `ListSecrets` only in accounts where inventory exposure is acceptable.
|
||||
- Keep preview/import board-only; agent API keys must not call these routes.
|
||||
- Treat AWS tag/name filters as search UX only, not permission enforcement.
|
||||
|
||||
Paperclip also blocks importing refs under its own managed namespace as
|
||||
external references. Use the Paperclip-managed flow for
|
||||
`paperclip/{deploymentId}/{companyId}/{secretKey}` resources.
|
||||
|
||||
## Existing AWS Secrets
|
||||
|
||||
V1 keeps existing AWS Secrets Manager entries as **linked external references**, not adopted
|
||||
Paperclip-managed resources.
|
||||
|
||||
Use the Paperclip-managed flow when Paperclip should create and rotate the value. The AWS
|
||||
secret name is derived from deployment and company scope:
|
||||
|
||||
```text
|
||||
paperclip/{deploymentId}/{companyId}/{secretKey}
|
||||
```
|
||||
|
||||
Use the external-reference flow when the secret already exists at an operator-owned path such
|
||||
as:
|
||||
|
||||
```text
|
||||
/paperclip-bench/anthropic_api_key
|
||||
```
|
||||
|
||||
In that mode Paperclip stores only the path or ARN, resolves it at runtime, and records
|
||||
redacted access events. Operators rotate the actual value in AWS. Update the Paperclip
|
||||
reference only when the AWS path, ARN, or pinned provider version changes.
|
||||
|
||||
Paperclip does not currently offer an "adopt existing AWS secret" flow that takes over future
|
||||
`PutSecretValue` writes for an arbitrary existing secret. Adding that later requires explicit
|
||||
confirmation UX, scope validation, expected Paperclip tags, and security/cloud-ops review.
|
||||
|
||||
## Data Custody
|
||||
|
||||
- Paperclip stores `externalRef`, `providerVersionRef`, provider id, fingerprint hash, status, and binding metadata.
|
||||
- Paperclip does not store AWS secret plaintext in `company_secret_versions.material`.
|
||||
- Runtime resolution fetches the value from AWS only when a bound consumer needs it.
|
||||
|
||||
## Rotation Runbook
|
||||
|
||||
Manual Paperclip-managed rotation:
|
||||
|
||||
1. Write the new value through the Paperclip secret rotate flow.
|
||||
2. Paperclip creates a new AWS secret version with `PutSecretValue`.
|
||||
3. Paperclip records the new `providerVersionRef` in `company_secret_versions`.
|
||||
4. Re-run or restart affected workloads that consume `latest`, or pin consumers to a specific Paperclip version before rollout when you need staged release safety.
|
||||
|
||||
Guidance:
|
||||
|
||||
- Prefer pinned Paperclip secret versions for risky rollouts.
|
||||
- Treat provider-native automatic rotation as a later enhancement; current V1 flow is explicit create-new-version plus controlled rollout.
|
||||
|
||||
## Backup And Restore Runbook
|
||||
|
||||
What must survive:
|
||||
|
||||
- Paperclip database metadata for secret ownership, bindings, status, and provider version refs.
|
||||
- AWS Secrets Manager namespace under the configured deployment prefix.
|
||||
- The configured KMS key and its decrypt permissions.
|
||||
|
||||
Restore checklist:
|
||||
|
||||
1. Restore Paperclip database metadata.
|
||||
2. Confirm the same AWS Secrets Manager namespace still exists.
|
||||
3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix.
|
||||
4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`.
|
||||
5. Run the live smoke below or a targeted runtime secret resolution test.
|
||||
|
||||
## Provider Outage Runbook
|
||||
|
||||
Symptoms:
|
||||
|
||||
- Secret create/rotate/resolve operations fail with AWS provider errors.
|
||||
- Agent runs fail before adapter invocation on required secret resolution.
|
||||
- Remote import preview fails to list AWS inventory.
|
||||
|
||||
Immediate actions:
|
||||
|
||||
1. Confirm AWS regional health and Secrets Manager availability.
|
||||
2. Confirm the runtime role still has `GetSecretValue` and KMS decrypt permissions.
|
||||
3. Check for accidental prefix, region, deployment id, or KMS key config drift.
|
||||
4. Retry a single resolution after AWS service health is green.
|
||||
5. If outage persists, pause high-risk runs that require secret access rather than churning retries.
|
||||
|
||||
Remote import-specific actions:
|
||||
|
||||
- Missing list permission: add `secretsmanager:ListSecrets` with
|
||||
`Resource: "*"` only when inventory import is approved for that vault's
|
||||
AWS account and Region.
|
||||
- Throttling: narrow the search, wait briefly, and retry with backoff. Avoid
|
||||
full-account enumeration.
|
||||
- Invalid or stale cursor: refresh the preview and discard the old
|
||||
`NextToken`.
|
||||
- Large account: load pages intentionally, keep one in-flight preview request
|
||||
per vault/search, and do not run background full-account crawls.
|
||||
- Runtime read failure after import: verify `GetSecretValue` and KMS decrypt
|
||||
on the selected external secret. Visibility in `ListSecrets` does not prove
|
||||
read permission.
|
||||
|
||||
## Incident Response Runbook
|
||||
|
||||
Potential incidents:
|
||||
|
||||
- Cross-company access caused by IAM scoping drift.
|
||||
- KMS policy drift causing decrypt failures or over-broad access.
|
||||
- Suspected secret exposure in logs, transcripts, or downstream agent output.
|
||||
|
||||
Response steps:
|
||||
|
||||
1. Stop or pause affected Paperclip runs.
|
||||
2. Audit recent Paperclip secret access events for impacted secret ids and consumers.
|
||||
3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`,
|
||||
`PutSecretValue`, and `DeleteSecret` calls on the relevant vault account,
|
||||
Region, deployment prefix, and approved external prefixes.
|
||||
4. Rotate impacted secrets in AWS through Paperclip-managed versioning.
|
||||
5. Re-scope IAM and KMS policies before resuming normal traffic.
|
||||
6. If a value may have reached an agent transcript or external system, treat it as exposed and rotate immediately.
|
||||
|
||||
## Optional Live Smoke
|
||||
|
||||
This is safe to skip locally. Run it only against a dedicated AWS test namespace.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- AWS credentials or workload identity with the deployment-scoped IAM permissions above.
|
||||
- `PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager`
|
||||
- The required `PAPERCLIP_SECRETS_AWS_*` environment variables set.
|
||||
|
||||
Suggested smoke:
|
||||
|
||||
1. Create a test secret through the Paperclip board or API under a throwaway company.
|
||||
2. Confirm the resulting AWS secret name matches `paperclip/{deploymentId}/{companyId}/{secretKey}`.
|
||||
3. Rotate the secret once and confirm a new `providerVersionRef` appears in Paperclip metadata.
|
||||
4. Resolve the secret through a bound runtime path, not by adding a general-purpose reveal endpoint.
|
||||
5. Delete the throwaway secret and confirm AWS schedules deletion with the configured recovery window.
|
||||
@@ -34,10 +34,10 @@ These decisions close open questions from `SPEC.md` for V1.
|
||||
| Company model | Company is first-order; all business entities are company-scoped |
|
||||
| Board | Single human board operator per deployment |
|
||||
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
|
||||
| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy |
|
||||
| Visibility | Full visibility to board and all agents in same company |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise create visible recovery issues or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
|
||||
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
|
||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||
@@ -150,7 +150,7 @@ Invariant: every business record belongs to exactly one company.
|
||||
- `capabilities` text null
|
||||
- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway`
|
||||
- `adapter_config` jsonb not null
|
||||
- `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config
|
||||
- `runtime_config` jsonb not null default `{}`
|
||||
- `default_environment_id` uuid fk `environments.id` null
|
||||
- `context_mode` enum: `thin | fat` default `thin`
|
||||
- `budget_monthly_cents` int not null default 0
|
||||
@@ -207,8 +207,6 @@ Invariant:
|
||||
|
||||
- project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected
|
||||
|
||||
Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent.
|
||||
|
||||
## 7.6 `issues` (core task entity)
|
||||
|
||||
- `id` uuid pk
|
||||
@@ -311,32 +309,7 @@ Invariant: each event must attach to agent and company; rollups are aggregation,
|
||||
- `details` jsonb null
|
||||
- `created_at` timestamptz not null default now()
|
||||
|
||||
## 7.12 `project_memberships` + `agent_memberships`
|
||||
|
||||
Per-user project/agent membership is personal visibility state for board users. It only controls whether a resource appears in the current user's sidebar; it must not grant or revoke access to all-pages, detail pages, selectors, assignment flows, search, or existing permissions.
|
||||
|
||||
`project_memberships`:
|
||||
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk `companies.id` not null
|
||||
- `project_id` uuid fk `projects.id` not null
|
||||
- `user_id` text not null
|
||||
- `state` enum-like text: `joined | left`
|
||||
- `created_at` timestamptz not null default now()
|
||||
- `updated_at` timestamptz not null default now()
|
||||
- unique `(company_id, user_id, project_id)`
|
||||
|
||||
`agent_memberships` mirrors the same shape with `agent_id` instead of `project_id` and unique `(company_id, user_id, agent_id)`.
|
||||
|
||||
Invariants:
|
||||
|
||||
- Missing membership rows mean `joined` for backward compatibility.
|
||||
- Mutations are board-user-only `/me` operations; agent API keys are rejected.
|
||||
- Viewer-role board users may update only their own membership rows through the narrow self-service helper.
|
||||
- Target project/agent ownership is checked against the path company before mutation.
|
||||
- Successful state changes write `resource_membership.joined` or `resource_membership.left` activity entries.
|
||||
|
||||
## 7.13 `company_secrets` + `company_secret_versions`
|
||||
## 7.12 `company_secrets` + `company_secret_versions`
|
||||
|
||||
- Secret values are not stored inline in `agents.adapter_config.env`.
|
||||
- Agent env entries should use secret refs for sensitive values.
|
||||
@@ -350,7 +323,7 @@ Operational policy:
|
||||
- Activity and approval payloads must not persist raw sensitive values.
|
||||
- Config revisions may include redacted placeholders; such revisions are non-restorable for redacted fields.
|
||||
|
||||
## 7.14 Required Indexes
|
||||
## 7.13 Required Indexes
|
||||
|
||||
- `agents(company_id, status)`
|
||||
- `agents(company_id, reports_to)`
|
||||
@@ -368,12 +341,8 @@ Operational policy:
|
||||
- `issue_attachments(company_id, issue_id)`
|
||||
- `company_secrets(company_id, name)` unique
|
||||
- `company_secret_versions(secret_id, version)` unique
|
||||
- `project_memberships(company_id, user_id)`
|
||||
- `project_memberships(company_id, user_id, project_id)` unique
|
||||
- `agent_memberships(company_id, user_id)`
|
||||
- `agent_memberships(company_id, user_id, agent_id)` unique
|
||||
|
||||
## 7.15 `assets` + `issue_attachments`
|
||||
## 7.14 `assets` + `issue_attachments`
|
||||
|
||||
- `assets` stores provider-backed object metadata (not inline bytes):
|
||||
- `id` uuid pk
|
||||
@@ -407,10 +376,6 @@ Operational policy:
|
||||
- `created_by_user_id` uuid/text fk null
|
||||
- `updated_by_agent_id` uuid fk null
|
||||
- `updated_by_user_id` uuid/text fk null
|
||||
- `locked_at` timestamptz null
|
||||
- `locked_by_agent_id` uuid fk null
|
||||
- `locked_by_user_id` uuid/text fk null
|
||||
- Locked documents are immutable until unlocked. Board operators can lock/unlock; agent writes to a locked key create a new issue document with a derived key instead of overwriting the locked document.
|
||||
- `document_revisions` stores append-only history:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
@@ -431,7 +396,7 @@ The current implementation includes additional V1-control-plane tables beyond th
|
||||
|
||||
- Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes.
|
||||
- Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables.
|
||||
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`.
|
||||
- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, and `routines`.
|
||||
- Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos.
|
||||
|
||||
## 8. State Machines
|
||||
@@ -469,10 +434,9 @@ Side effects:
|
||||
V1 non-terminal liveness rule:
|
||||
|
||||
- agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path
|
||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery action owns the next action
|
||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery issue owns the next action
|
||||
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
|
||||
- when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
|
||||
- explicit recovery actions are the liveness primitive; source-scoped actions are the default form, issue-backed recovery is a fallback for independent repair work or safety boundaries, and comments alone are evidence rather than a healthy liveness path
|
||||
|
||||
Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and non-terminal liveness semantics are documented in `doc/execution-semantics.md`.
|
||||
|
||||
@@ -516,59 +480,6 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
|
||||
| Report cost | yes | yes |
|
||||
| Set company budget | yes | no |
|
||||
| Set subordinate budget | yes | yes (manager subtree only) |
|
||||
| Set work-object visibility (issue/project) | no | no (pro gate) |
|
||||
|
||||
## 9.4 Permission Terminology and Default Visibility Rule
|
||||
|
||||
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.
|
||||
|
||||
The approved term set is:
|
||||
|
||||
- **Agent profile visibility**: identity-level facts needed for delegation and governance (name, role, capabilities, reporting lines).
|
||||
- **Agent config visibility**: adapter/runtime config metadata and secret-access policy.
|
||||
- **Assignment/invocation permission**: who may modify or execute a task.
|
||||
- **Work-object visibility**: who can read/write issues, comments, projects, and attachments.
|
||||
- **Tool/secret policy**: what tools and secret-backed credentials an agent can use and what appears in logs.
|
||||
- **Escalation authority**: where refusal/blocked decisions route (manager, then board).
|
||||
|
||||
## 9.5 Core V1 Rule: what “private” means
|
||||
|
||||
- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
|
||||
- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
|
||||
- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
|
||||
|
||||
## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
|
||||
|
||||
| Permission area | Free / V1 default | Pro / Enterprise |
|
||||
|---|---|---|
|
||||
| Company boundary | Hard boundary only (`company_id`) | Multi-company policy overlays (`membership`, `project`, and `task` scopes) |
|
||||
| Simple roles | Board + agent roles with existing approval/budget gates | Additional role aliases + scoped approver roles |
|
||||
| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
|
||||
| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
|
||||
| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
|
||||
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
|
||||
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
|
||||
| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows |
|
||||
|
||||
## 9.7 Recommended first-slice implementation order
|
||||
|
||||
1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates.
|
||||
2. Treat profile privacy as external-facing signal only; do not use it to hide company-visible work objects.
|
||||
3. Enforce assignment/invocation coupling (`assignee`/`agent` checks, checkout semantics, invocation checks).
|
||||
4. Standardize read-path redaction for secrets and secret references, including logs and activity.
|
||||
5. Standardize escalation paths (`blocked` and refusal) so non-board agents hand off by manager/board with immutable audit.
|
||||
|
||||
## 9.8 Scoped Task Assignment Grants
|
||||
|
||||
`tasks:assign` remains the broad assignment permission. Existing unscoped grants preserve compatibility and allow the principal to assign any visible company task within normal company-boundary checks.
|
||||
|
||||
`tasks:assign_scope` is the constrained assignment permission. Its `principal_permission_grants.scope` JSON must include at least one recognized constraint:
|
||||
|
||||
- Project scope: `projectId`, `projectIds`, or `allow: ["project:<projectId>"]`.
|
||||
- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:<agentId>"]`.
|
||||
- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:<agentId>"]`.
|
||||
|
||||
When multiple constraint families are present, assignment must satisfy all of them. Denials return `403` with a generic scope explanation and do not disclose details about hidden or unrelated resources.
|
||||
|
||||
## 10. API Contract (REST)
|
||||
|
||||
@@ -612,8 +523,6 @@ All endpoints are under `/api` and return JSON.
|
||||
- `GET /issues/:issueId/documents`
|
||||
- `GET /issues/:issueId/documents/:key`
|
||||
- `PUT /issues/:issueId/documents/:key`
|
||||
- `POST /issues/:issueId/documents/:key/lock`
|
||||
- `POST /issues/:issueId/documents/:key/unlock`
|
||||
- `GET /issues/:issueId/documents/:key/revisions`
|
||||
- `DELETE /issues/:issueId/documents/:key`
|
||||
- `POST /issues/:issueId/checkout`
|
||||
@@ -652,28 +561,14 @@ Server behavior:
|
||||
- `GET /projects/:projectId`
|
||||
- `PATCH /projects/:projectId`
|
||||
|
||||
## 10.6 Current-user Resource Memberships
|
||||
|
||||
- `GET /companies/:companyId/resource-memberships/me`
|
||||
- `PUT /companies/:companyId/resource-memberships/me/projects/:projectId`
|
||||
- `PUT /companies/:companyId/resource-memberships/me/agents/:agentId`
|
||||
|
||||
Request payload:
|
||||
|
||||
```json
|
||||
{ "state": "joined" }
|
||||
```
|
||||
|
||||
Allowed states are `joined` and `left`. Endpoints require a concrete board user and active company membership, reject agent API keys, and only mutate the caller's own sidebar visibility state. Joining/leaving is idempotent; missing rows read as `joined`.
|
||||
|
||||
## 10.7 Approvals
|
||||
## 10.6 Approvals
|
||||
|
||||
- `GET /companies/:companyId/approvals?status=pending`
|
||||
- `POST /companies/:companyId/approvals`
|
||||
- `POST /approvals/:approvalId/approve`
|
||||
- `POST /approvals/:approvalId/reject`
|
||||
|
||||
## 10.8 Cost and Budgets
|
||||
## 10.7 Cost and Budgets
|
||||
|
||||
- `POST /companies/:companyId/cost-events`
|
||||
- `GET /companies/:companyId/costs/summary`
|
||||
@@ -682,7 +577,7 @@ Allowed states are `joined` and `left`. Endpoints require a concrete board user
|
||||
- `PATCH /companies/:companyId/budgets`
|
||||
- `PATCH /agents/:agentId/budgets`
|
||||
|
||||
## 10.9 Activity and Dashboard
|
||||
## 10.8 Activity and Dashboard
|
||||
|
||||
- `GET /companies/:companyId/activity`
|
||||
- `GET /companies/:companyId/dashboard`
|
||||
@@ -694,7 +589,7 @@ Dashboard payload must include:
|
||||
- month-to-date spend and budget utilization
|
||||
- pending approvals count
|
||||
|
||||
## 10.10 Error Semantics
|
||||
## 10.9 Error Semantics
|
||||
|
||||
- `400` validation error
|
||||
- `401` unauthenticated
|
||||
@@ -704,7 +599,7 @@ Dashboard payload must include:
|
||||
- `422` semantic rule violation
|
||||
- `500` server error
|
||||
|
||||
## 10.11 Current Implementation API Addenda
|
||||
## 10.10 Current Implementation API Addenda
|
||||
|
||||
The current app also exposes V1-supporting surfaces for:
|
||||
|
||||
@@ -775,19 +670,13 @@ Behavior:
|
||||
- `thin`: send IDs and pointers only; agent fetches context via API
|
||||
- `fat`: include current assignments, goal summary, budget snapshot, and recent comments
|
||||
|
||||
## 11.5 Recovery Model Profiles
|
||||
|
||||
The optional `modelProfiles.cheap` lane is not a retry worker lane. Paperclip may request the cheap profile only for status-only recovery coordination, and those wakes must include guard context that prevents deliverable work and document/plan updates (`allowDeliverableWork: false`, `allowDocumentUpdates: false`, `resumeRequiresNormalModel: true`).
|
||||
|
||||
Failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, and downstream source-work child/requeue/resume contexts must use the normal/original model lane. If cheap recovery repairs liveness while actual work remains, the next live continuation path must be a separate normal-model worker run with cheap hints scrubbed.
|
||||
|
||||
## 11.6 Scheduler Rules
|
||||
## 11.5 Scheduler Rules
|
||||
|
||||
Per-agent schedule fields in `adapter_config`:
|
||||
|
||||
- `enabled` boolean
|
||||
- `intervalSec` integer (minimum 30)
|
||||
- `maxConcurrentRuns` integer; new agents default to `20`; scheduler clamps configured values to `1..50`
|
||||
- `maxConcurrentRuns` integer; new agents default to `5`
|
||||
|
||||
Scheduler must skip invocation when:
|
||||
|
||||
|
||||
@@ -141,8 +141,6 @@ Hierarchical reporting structure. CEO at top, reports cascade down.
|
||||
|
||||
**Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control.
|
||||
|
||||
Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place.
|
||||
|
||||
Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what.
|
||||
|
||||
### Cross-Team Work
|
||||
|
||||
|
Before Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,7 +1,7 @@
|
||||
# Execution Semantics
|
||||
|
||||
Status: Current implementation guide
|
||||
Date: 2026-05-23
|
||||
Date: 2026-04-26
|
||||
Audience: Product and engineering
|
||||
|
||||
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
|
||||
@@ -67,15 +67,13 @@ This is the right state for:
|
||||
|
||||
- waiting on another issue
|
||||
- waiting on a human decision
|
||||
- waiting on an external dependency or system when Paperclip does not own a scheduled re-check
|
||||
- waiting on an external dependency or system
|
||||
- work that automatic recovery could not safely continue
|
||||
|
||||
### `in_review`
|
||||
|
||||
Execution work is paused because the next move belongs to a reviewer or approver, not the current executor.
|
||||
|
||||
An external review service can also be a valid review path when the issue keeps an agent assignee and has an active one-shot monitor that will wake that assignee to check the service later.
|
||||
|
||||
### `done`
|
||||
|
||||
The work is complete and terminal.
|
||||
@@ -152,77 +150,11 @@ Blocked issues should stay idle while blockers remain unresolved. Paperclip shou
|
||||
|
||||
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
|
||||
|
||||
## 7. Accepted-Plan Decomposition
|
||||
|
||||
An accepted plan confirmation is permission to decompose one specific accepted plan revision into child issues.
|
||||
|
||||
This complements the existing accepted-plan continuation rule: once a plan is accepted, the source issue may create child implementation issues, but it must not start implementation work on the source issue itself during that continuation.
|
||||
|
||||
Paperclip must treat accepted-plan decomposition as an exact-once control-plane primitive, not as a free-floating wake that any later run may interpret again.
|
||||
|
||||
### Exact-once fingerprint
|
||||
|
||||
The canonical decomposition fingerprint is:
|
||||
|
||||
- `(sourceIssueId, acceptedPlanRevisionId)`
|
||||
|
||||
Where:
|
||||
|
||||
- `sourceIssueId` is the issue whose `plan` document revision was accepted
|
||||
- `acceptedPlanRevisionId` is the accepted `plan` document revision
|
||||
|
||||
This is the product contract because the accepted revision is the thing being authorized for decomposition. Re-accepting, re-waking, or re-reading the same accepted revision must not authorize a second child tree. A later accepted revision on the same source issue is a new fingerprint and may produce a different decomposition result.
|
||||
|
||||
An implementation may also store the accepted interaction id, acceptance run id, or other evidence, but those values must collapse onto the same uniqueness guarantee. They must not allow a second decomposition claim for the same `(sourceIssueId, acceptedPlanRevisionId)` pair.
|
||||
|
||||
### Durable claim and durable result
|
||||
|
||||
Before creating child issues, the first decomposition attempt must create or reuse a durable record for the fingerprint.
|
||||
|
||||
That durable record must be able to answer, without reconstructing the thread from comments or transcripts:
|
||||
|
||||
- whether decomposition for the fingerprint is `in_flight` or `completed`
|
||||
- which run or owner currently holds the in-flight claim
|
||||
- which child issues, if any, have already been created under that fingerprint
|
||||
- which final child issue ids belong to the completed result
|
||||
|
||||
Paperclip does not need to mandate a specific storage shape in this document. The record may live in a dedicated table, source-issue execution state, interaction metadata, or another durable product surface. What matters is the contract:
|
||||
|
||||
- the claim is durable before fan-out starts
|
||||
- partial progress is durable while fan-out is underway
|
||||
- the completed child result set is durable after fan-out finishes
|
||||
|
||||
If a run creates some children and then dies, retries must continue from the same fingerprint and reuse the already-recorded partial result. They must not restart decomposition as if nothing happened.
|
||||
|
||||
### Parent live path while decomposition is in flight
|
||||
|
||||
While decomposition for an accepted fingerprint is incomplete, the source issue must expose an explicit live path for that same fingerprint.
|
||||
|
||||
The accepted interaction by itself is only evidence that the plan was approved. It is not a sufficient live path once decomposition begins. The source issue must make it clear what moves the fingerprint forward next, such as:
|
||||
|
||||
- the active decomposition run
|
||||
- a queued continuation wake for the same assignee
|
||||
- a monitor or explicit recovery action tied to the same decomposition claim
|
||||
- a blocked state that names the real blocker for finishing that claimed decomposition
|
||||
|
||||
If the live run disappears, Paperclip must repair, resume, or visibly block the existing claim. It must not leave the source issue in a state where a second run can interpret the same acceptance as fresh permission to create sibling issues again.
|
||||
|
||||
### Concurrent and repeat attempts
|
||||
|
||||
Every later run that encounters the same accepted-plan fingerprint must consult the durable claim/result before creating children.
|
||||
|
||||
- If no claim exists, the run may atomically create the claim and become the decomposition owner.
|
||||
- If a claim exists and is `in_flight`, the later run must reuse that claim. It may resume the same decomposition if it is the valid continuation owner, or it may exit after observing that another run already owns the work.
|
||||
- If a claim exists and is `completed`, the later run must reuse the recorded child result and must not create new sibling issues.
|
||||
- If the prior attempt ended after partial child creation, the retry must continue under the same fingerprint and preserve the already-created child ids.
|
||||
|
||||
Concurrent accepted-plan runs are therefore idempotent relative to the fingerprint. Creating multiple child trees for the same `(sourceIssueId, acceptedPlanRevisionId)` pair is a product bug.
|
||||
|
||||
## 8. Non-Terminal Issue Liveness Contract
|
||||
## 7. Non-Terminal Issue Liveness Contract
|
||||
|
||||
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
|
||||
|
||||
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible notice, or an explicit recovery action. It must not silently mark work done from prose comments or guess that a dependency is complete.
|
||||
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible comment, or an explicit recovery issue. It must not silently mark work done from prose comments or guess that a dependency is complete.
|
||||
|
||||
An issue is healthy when the product can answer "what moves this forward next?" without requiring a human to reconstruct intent from the whole thread. An issue is stalled when it is non-terminal but has no live execution path, no explicit waiting path, and no recovery path.
|
||||
|
||||
@@ -232,43 +164,9 @@ The valid action-path primitives are:
|
||||
- a queued wake or continuation that can be delivered to the responsible agent
|
||||
- a typed execution-policy participant, such as `executionState.currentParticipant`
|
||||
- a pending issue-thread interaction or linked approval that is waiting for a specific responder
|
||||
- a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check
|
||||
- a human owner via `assigneeUserId`
|
||||
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
|
||||
- an open explicit recovery action that names the owner and action needed to restore liveness
|
||||
|
||||
### Explicit recovery actions
|
||||
|
||||
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
|
||||
|
||||
A valid recovery action must name:
|
||||
|
||||
- the source issue and company
|
||||
- the recovery kind and idempotency fingerprint
|
||||
- the recovery owner, plus previous or return owner when ownership may temporarily shift
|
||||
- the cause, bounded evidence, and next action
|
||||
- the wake, monitor, timeout, retry, or escalation policy that will move the action forward
|
||||
- the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled
|
||||
|
||||
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: move the source issue back to `todo` so it can be retried, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
|
||||
|
||||
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
|
||||
|
||||
- long or cross-agent repair work with its own assignee, subtasks, or blockers
|
||||
- real delegated follow-up that should block the source issue as a first-class dependency
|
||||
- active-run watchdog work that must observe a still-running source process without interfering with it
|
||||
- recovery that needs separate review, approval, security handling, or escalation ownership
|
||||
- cases where source issue ownership cannot be changed or restored safely
|
||||
|
||||
A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome.
|
||||
|
||||
#### Recovery action freshness
|
||||
|
||||
Source-scoped recovery actions are snapshots of the source issue's liveness state at the time the action was opened. They must be revalidated after newer durable source activity, including source issue status changes, assignee changes, blocker changes, execution policy or monitor changes, document or work-product updates that define a valid waiting path, and structured resume or disposition updates.
|
||||
|
||||
When newer source activity restores a valid live or waiting path, the recovery action is stale and should be folded through the explicit recovery lifecycle instead of being hidden or deleted. Folding means resolving or cancelling the recovery action with a resolution outcome and note that preserve the audit trail.
|
||||
|
||||
Plain comments alone do not make a recovery action stale. A comment can provide evidence, but the recovery action should remain visible when the source issue is still stalled and the comment does not create a valid action-path primitive such as a wake, monitor, interaction, approval, blocker, human owner, execution participant, terminal disposition, or delegated follow-up.
|
||||
- an open explicit recovery issue that names the owner and action needed to restore liveness
|
||||
|
||||
### Agent-assigned `todo`
|
||||
|
||||
@@ -282,16 +180,6 @@ A healthy dispatch state means at least one of these is true:
|
||||
|
||||
An assigned `todo` issue is stalled when dispatch was interrupted, no wake remains queued or running, and no recovery path has been opened.
|
||||
|
||||
### Agent-assigned `backlog`
|
||||
|
||||
This is parked state, not dispatch state.
|
||||
|
||||
Assigning an issue normally implies executable intent. When create APIs receive an assignee and no explicit status, Paperclip defaults the issue to `todo` so the assignee has a wake path instead of silently inheriting the unassigned `backlog` default.
|
||||
|
||||
An explicit assigned `backlog` issue remains valid when the creator is deliberately parking the work. It must not wake the assignee just because it has an assignee. Paperclip should make that choice visible in activity and UI so operators can distinguish intentional parking from a missed handoff.
|
||||
|
||||
An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery action. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy.
|
||||
|
||||
### Agent-assigned `in_progress`
|
||||
|
||||
This is active-work state.
|
||||
@@ -300,8 +188,7 @@ A healthy active-work state means at least one of these is true:
|
||||
|
||||
- there is an active run for the issue
|
||||
- there is already a queued continuation wake
|
||||
- there is an active one-shot monitor that will wake the assignee for a future check
|
||||
- there is an open explicit recovery action for the lost execution path
|
||||
- there is an open explicit recovery issue for the lost execution path
|
||||
|
||||
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
|
||||
|
||||
@@ -315,34 +202,11 @@ A healthy `in_review` issue has at least one valid action path:
|
||||
- a pending issue-thread interaction or linked approval waiting for a named responder
|
||||
- a human owner via `assigneeUserId`
|
||||
- an active run or queued wake that is expected to process the review state
|
||||
- an active one-shot monitor for an external service or async review loop that the assignee owns
|
||||
- an open explicit recovery action for an ambiguous review handoff
|
||||
- an open explicit recovery issue for an ambiguous review handoff
|
||||
|
||||
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
|
||||
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
|
||||
### Issue monitors
|
||||
|
||||
An issue monitor is a one-shot deferred action path for agent-owned issues in `in_progress` or `in_review`.
|
||||
|
||||
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
|
||||
|
||||
Monitor policy lives under `executionPolicy.monitor` and includes:
|
||||
|
||||
- `nextCheckAt`: when Paperclip should wake the assignee
|
||||
- `notes`: non-secret instructions for what the assignee should check
|
||||
- `serviceName`: optional non-secret external-service context
|
||||
- `externalRef`: optional external-service reference input; Paperclip treats it as secret-adjacent, redacts it before persistence/visibility, and omits it from activity and wake payloads
|
||||
- `timeoutAt`, `maxAttempts`, and `recoveryPolicy`: optional recovery hints for bounded waits
|
||||
|
||||
Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared.
|
||||
|
||||
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
|
||||
|
||||
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
|
||||
|
||||
Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work.
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
|
||||
### `blocked`
|
||||
|
||||
@@ -351,20 +215,20 @@ This is explicit waiting state.
|
||||
A healthy `blocked` issue has an explicit waiting path:
|
||||
|
||||
- first-class blockers exist, and each unresolved leaf has a valid action path under this contract
|
||||
- the issue has an explicit recovery action that itself has a live or waiting path
|
||||
- the issue is blocked on an explicit recovery issue that itself has a live or waiting path
|
||||
- the issue is waiting on a pending interaction, linked approval, human owner, or clearly named external owner/action
|
||||
|
||||
A blocker chain is covered only when its unresolved leaf is live or explicitly waiting. An intermediate `blocked` issue does not make the chain healthy by itself.
|
||||
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery issue. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
|
||||
## 9. Crash and Restart Recovery
|
||||
## 8. Crash and Restart Recovery
|
||||
|
||||
Paperclip now treats crash/restart recovery as a stranded-assigned-work problem, not just a stranded-run problem.
|
||||
|
||||
There are two distinct failure modes.
|
||||
|
||||
### 9.1 Stranded assigned `todo`
|
||||
### 8.1 Stranded assigned `todo`
|
||||
|
||||
Example:
|
||||
|
||||
@@ -376,11 +240,11 @@ Example:
|
||||
Recovery rule:
|
||||
|
||||
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery wake
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
This is a dispatch recovery, not a continuation recovery.
|
||||
|
||||
### 9.2 Stranded assigned `in_progress`
|
||||
### 8.2 Stranded assigned `in_progress`
|
||||
|
||||
Example:
|
||||
|
||||
@@ -392,31 +256,24 @@ Example:
|
||||
Recovery rule:
|
||||
|
||||
- Paperclip queues one automatic continuation wake
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
This is an active-work continuity recovery.
|
||||
|
||||
### 9.3 Recovery model-profile lane
|
||||
|
||||
Cheap model profiles are only for status-only operational recovery overhead. Paperclip may request `modelProfile: "cheap"` for bounded recovery-owner work that updates task liveness, clears bad status, records a disposition, or asks for human/manager intervention. Those wakes must carry guard context such as `allowDeliverableWork: false`, `allowDocumentUpdates: false`, and `resumeRequiresNormalModel: true`.
|
||||
|
||||
Automatic retries that can continue source work must use the original/normal model lane. This includes failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, assigned-todo dispatch recovery, and any run that can update repo files, issue documents, plans, work products, or attachments. When a cheap status-only recovery determines that actual work remains, it must hand back to a normal-model worker run before source work or persistent deliverable updates resume. Cheap recovery hints must be scrubbed from copied retry, resume, child, and downstream source-work contexts.
|
||||
|
||||
## 10. Startup and Periodic Reconciliation
|
||||
## 9. Startup and Periodic Reconciliation
|
||||
|
||||
Startup recovery and periodic recovery are different from normal wakeup delivery.
|
||||
|
||||
On startup and on the periodic recovery loop, Paperclip now does five things in sequence:
|
||||
On startup and on the periodic recovery loop, Paperclip now does four things in sequence:
|
||||
|
||||
1. reap orphaned `running` runs
|
||||
2. resume persisted `queued` runs
|
||||
3. reconcile stranded assigned work
|
||||
4. scan silent active runs, revalidate their source issues, and either fold source-resolved watchdogs or create/update explicit watchdog recovery actions
|
||||
5. reconcile productivity reviews
|
||||
4. scan silent active runs and create or update explicit watchdog review issues
|
||||
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
|
||||
|
||||
## 11. Silent Active-Run Watchdog
|
||||
## 10. Silent Active-Run Watchdog
|
||||
|
||||
An active run can still be unhealthy even when its process is `running`. Paperclip treats prolonged output silence as a watchdog signal, not as proof that the run is failed.
|
||||
|
||||
@@ -425,11 +282,11 @@ The recovery service owns this contract:
|
||||
- classify active-run output silence as `ok`, `suspicious`, `critical`, `snoozed`, or `not_applicable`
|
||||
- collect bounded evidence from run logs, recent run events, child issues, and blockers
|
||||
- preserve redaction and truncation before evidence is written to issue descriptions
|
||||
- create at most one open watchdog recovery action per run; issue-backed implementations use `stale_active_run_evaluation` issues
|
||||
- create at most one open `stale_active_run_evaluation` issue per run
|
||||
- honor active snooze decisions before creating more review work
|
||||
- build the `outputSilence` summary shown by live-run and active-run API responses
|
||||
|
||||
Suspicious silence creates a medium-priority watchdog recovery action for the selected recovery owner. Critical silence raises that recovery action to high priority and, when issue-backed evaluation is needed for correctness, blocks the source issue on the explicit evaluation task without cancelling the active process.
|
||||
Suspicious silence creates a medium-priority review issue for the selected recovery owner. Critical silence raises that review issue to high priority and blocks the source issue on the explicit evaluation task without cancelling the active process.
|
||||
|
||||
Watchdog decisions are explicit operator/recovery-owner decisions:
|
||||
|
||||
@@ -439,36 +296,9 @@ Watchdog decisions are explicit operator/recovery-owner decisions:
|
||||
|
||||
Operators should prefer `snooze` for known time-bounded quiet periods. `continue` is only a short acknowledgement of the current evidence; if the run remains silent after the re-arm window, the periodic watchdog scan can create or update review work again.
|
||||
|
||||
The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
|
||||
The board can record watchdog decisions. The assigned owner of the watchdog evaluation issue can also record them. Other agents cannot.
|
||||
|
||||
### Source-aware watchdog folding
|
||||
|
||||
Active-run watchdog work is source-aware. Before the watchdog creates, refreshes, escalates, or blocks on reviewer work, it must re-read the linked source issue and decide whether the watchdog signal is still about productive source work or only about stale run/process bookkeeping.
|
||||
|
||||
Fold watchdog work when all of these are true:
|
||||
|
||||
- the run is linked to a source issue in the same company
|
||||
- the source issue is terminal (`done` or `cancelled`)
|
||||
- durable source activity from the same run proves the source issue reached that terminal disposition after the stale-run or output-silence evidence point
|
||||
- there is no independent evidence that the still-running or detached process is doing harmful work, still owns external cleanup that needs an operator decision, or needs a separate security/ownership review
|
||||
|
||||
Folding means resolving or cancelling the watchdog recovery action or issue-backed evaluation through the explicit recovery lifecycle. It must preserve the run id, source issue, detected silence or detached-process evidence, terminal source activity, decision reason, and best-effort process cleanup result. It must be idempotent for the `(companyId, runId, sourceIssueId)` signal and must not recursively recover the watchdog evaluation issue itself.
|
||||
|
||||
Do not fold watchdog work only because the run is quiet. The watchdog must still create or continue reviewer work when:
|
||||
|
||||
- the source issue is still `todo` or `in_progress`, because productive work may still be happening or stuck
|
||||
- the source issue remains `in_progress` after a successful run with no valid disposition, because the successful-run handoff path owns that bounded correction
|
||||
- the run terminated or disappeared while the source issue remains `in_progress` without a live path, because stranded assigned recovery owns that continuity repair
|
||||
- the source issue is terminal but there is no durable same-run terminal activity after the stale evidence point
|
||||
- there is independent evidence that the process may still be mutating external state, leaking resources, crossing company or ownership boundaries, or otherwise needs operator review
|
||||
|
||||
In the normal non-terminal case, critical silence can still create issue-backed evaluation work and block the source issue when blocking is necessary for correctness. In the source-resolved case, a completed source issue should not acquire a new manager review or blocker merely because an old run handle stayed active; only real unresolved work should block work.
|
||||
|
||||
This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other.
|
||||
|
||||
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
|
||||
|
||||
## 12. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
|
||||
Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
|
||||
|
||||
@@ -484,9 +314,9 @@ Examples:
|
||||
|
||||
Auto-recovery preserves the existing owner. It does not choose a replacement agent.
|
||||
|
||||
### Explicit Recovery Action
|
||||
### Explicit Recovery Issue
|
||||
|
||||
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.
|
||||
Paperclip creates an explicit recovery issue when the system can identify a problem but cannot safely complete the work itself.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -494,11 +324,9 @@ Examples:
|
||||
- a dependency graph has an invalid/uninvokable owner, unassigned blocker, or invalid review participant
|
||||
- an active run is silent past the watchdog threshold
|
||||
|
||||
The recovery action stays source-scoped by default. The source issue should show the recovery owner, cause, evidence, next action, and wake or monitor policy in its own thread/detail surface.
|
||||
The source issue remains visible and blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, or record the reason it is a false positive.
|
||||
|
||||
Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive.
|
||||
|
||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery issues automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
||||
|
||||
### Human Escalation
|
||||
|
||||
@@ -512,7 +340,7 @@ Examples:
|
||||
|
||||
In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying.
|
||||
|
||||
## 13. What This Does Not Mean
|
||||
## 12. What This Does Not Mean
|
||||
|
||||
These semantics do not change V1 into an auto-reassignment system.
|
||||
|
||||
@@ -526,10 +354,10 @@ The recovery model is intentionally conservative:
|
||||
|
||||
- preserve ownership
|
||||
- retry once when the control plane lost execution continuity
|
||||
- open an explicit recovery action when the system can identify a bounded recovery owner/action
|
||||
- create explicit recovery work when the system can identify a bounded recovery owner/action
|
||||
- escalate visibly when the system cannot safely keep going
|
||||
|
||||
## 14. Practical Interpretation
|
||||
## 13. Practical Interpretation
|
||||
|
||||
For a board operator, the intended meaning is:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 2026-03-14 Adapter Skill Sync Rollout
|
||||
|
||||
Status: Implemented for local adapters; gateway remains unsupported
|
||||
Status: Proposed
|
||||
Date: 2026-03-14
|
||||
Audience: Product and engineering
|
||||
Related:
|
||||
@@ -25,10 +25,8 @@ Paperclip currently has these adapters:
|
||||
|
||||
- `claude_local`
|
||||
- `codex_local`
|
||||
- `cursor`
|
||||
- `cursor_local`
|
||||
- `gemini_local`
|
||||
- `grok_local`
|
||||
- `acpx_local`
|
||||
- `opencode_local`
|
||||
- `pi_local`
|
||||
- `openclaw_gateway`
|
||||
@@ -41,14 +39,12 @@ The current skill API supports:
|
||||
|
||||
Current implementation state:
|
||||
|
||||
- `codex_local`: implemented, `ephemeral`
|
||||
- `codex_local`: implemented, `persistent`
|
||||
- `claude_local`: implemented, `ephemeral`
|
||||
- `cursor`: implemented, `persistent`
|
||||
- `gemini_local`: implemented, `persistent`
|
||||
- `pi_local`: implemented, `persistent`
|
||||
- `opencode_local`: implemented, `persistent`, with shared Claude skills home caveats
|
||||
- `acpx_local`: implemented, `ephemeral` for Claude/Codex sub-agents and `unsupported` for custom commands
|
||||
- `grok_local`: implemented, `ephemeral`
|
||||
- `cursor_local`: not yet implemented, but technically suited to `persistent`
|
||||
- `gemini_local`: not yet implemented, but technically suited to `persistent`
|
||||
- `pi_local`: not yet implemented, but technically suited to `persistent`
|
||||
- `opencode_local`: not yet implemented; likely `persistent`, but with special handling because it currently injects into Claude’s shared skills home
|
||||
- `openclaw_gateway`: not yet implemented; blocked on gateway protocol support, so `unsupported` for now
|
||||
|
||||
## 3. Product Principles
|
||||
@@ -68,7 +64,8 @@ These adapters have a stable local skills directory that Paperclip can read and
|
||||
|
||||
Candidates:
|
||||
|
||||
- `cursor`
|
||||
- `codex_local`
|
||||
- `cursor_local`
|
||||
- `gemini_local`
|
||||
- `pi_local`
|
||||
- `opencode_local` with caveats
|
||||
@@ -87,10 +84,7 @@ These adapters do not have a meaningful Paperclip-owned persistent install state
|
||||
|
||||
Current adapter:
|
||||
|
||||
- `codex_local`
|
||||
- `claude_local`
|
||||
- `acpx_local` when configured for Claude or Codex
|
||||
- `grok_local`
|
||||
|
||||
Expected UX:
|
||||
|
||||
@@ -105,7 +99,6 @@ These adapters cannot support skill sync without new external capabilities.
|
||||
|
||||
Current adapter:
|
||||
|
||||
- `acpx_local` when configured for custom commands
|
||||
- `openclaw_gateway`
|
||||
|
||||
Expected UX:
|
||||
@@ -121,7 +114,7 @@ Expected UX:
|
||||
|
||||
Target mode:
|
||||
|
||||
- `ephemeral`
|
||||
- `persistent`
|
||||
|
||||
Current state:
|
||||
|
||||
@@ -129,15 +122,15 @@ Current state:
|
||||
|
||||
Requirements to finish:
|
||||
|
||||
- keep runtime-mounted snapshots separate from persistent install snapshots
|
||||
- ensure imported company skills can be attached and mounted without manual path work
|
||||
- keep `CODEX_HOME/skills` mutation scoped to heartbeat execution, not `skills/sync`
|
||||
- keep as reference implementation
|
||||
- tighten tests around external custom skills and stale removal
|
||||
- ensure imported company skills can be attached and synced without manual path work
|
||||
|
||||
Success criteria:
|
||||
|
||||
- desired skills are stored in Paperclip
|
||||
- selected skills are linked into the effective `CODEX_HOME/skills` during runs
|
||||
- no persistent installed/stale state is reported from `skills/sync`
|
||||
- list installed managed and external skills
|
||||
- sync desired skills into `CODEX_HOME/skills`
|
||||
- preserve external user-managed skills
|
||||
|
||||
### 5.2 Claude Local
|
||||
|
||||
@@ -169,11 +162,18 @@ Target mode:
|
||||
|
||||
Technical basis:
|
||||
|
||||
- Paperclip reconciles desired skills into `~/.cursor/skills`
|
||||
- runtime already injects Paperclip skills into `~/.cursor/skills`
|
||||
|
||||
Current state:
|
||||
Implementation work:
|
||||
|
||||
- implemented
|
||||
1. Add `listSkills` for Cursor.
|
||||
2. Add `syncSkills` for Cursor.
|
||||
3. Reuse the same managed-symlink pattern as Codex.
|
||||
4. Distinguish:
|
||||
- managed Paperclip skills
|
||||
- external skills already present
|
||||
- missing desired skills
|
||||
- stale managed skills
|
||||
|
||||
Testing:
|
||||
|
||||
@@ -194,11 +194,14 @@ Target mode:
|
||||
|
||||
Technical basis:
|
||||
|
||||
- Paperclip reconciles desired skills into `~/.gemini/skills`
|
||||
- runtime already injects Paperclip skills into `~/.gemini/skills`
|
||||
|
||||
Current state:
|
||||
Implementation work:
|
||||
|
||||
- implemented
|
||||
1. Add `listSkills` for Gemini.
|
||||
2. Add `syncSkills` for Gemini.
|
||||
3. Reuse managed-symlink conventions from Codex/Cursor.
|
||||
4. Verify auth remains untouched while skills are reconciled.
|
||||
|
||||
Potential caveat:
|
||||
|
||||
@@ -216,11 +219,14 @@ Target mode:
|
||||
|
||||
Technical basis:
|
||||
|
||||
- Paperclip reconciles desired skills into `~/.pi/agent/skills`
|
||||
- runtime already injects Paperclip skills into `~/.pi/agent/skills`
|
||||
|
||||
Current state:
|
||||
Implementation work:
|
||||
|
||||
- implemented
|
||||
1. Add `listSkills` for Pi.
|
||||
2. Add `syncSkills` for Pi.
|
||||
3. Reuse managed-symlink helpers.
|
||||
4. Verify session-file behavior remains independent from skill sync.
|
||||
|
||||
Success criteria:
|
||||
|
||||
@@ -244,7 +250,9 @@ This is product-risky because:
|
||||
|
||||
Plan:
|
||||
|
||||
- implemented `listSkills` and `syncSkills`
|
||||
Phase 1:
|
||||
|
||||
- implement `listSkills` and `syncSkills`
|
||||
- treat it as `persistent`
|
||||
- explicitly label the home as shared in UI copy
|
||||
- only remove stale managed Paperclip skills that are clearly marked as Paperclip-managed
|
||||
@@ -282,30 +290,6 @@ Future target:
|
||||
- likely a fourth truth model eventually, such as remote-managed persistent state
|
||||
- for now, keep the current API and treat gateway as unsupported
|
||||
|
||||
### 5.8 ACPX Local
|
||||
|
||||
Target mode:
|
||||
|
||||
- `ephemeral` for built-in Claude/Codex ACPX sub-agents
|
||||
- `unsupported` for custom ACP commands
|
||||
|
||||
Success criteria:
|
||||
|
||||
- Claude/Codex ACPX snapshots show skills as configured for the next session
|
||||
- custom command snapshots keep desired skills tracked only and do not imply runtime sync
|
||||
|
||||
### 5.9 Grok Local
|
||||
|
||||
Target mode:
|
||||
|
||||
- `ephemeral`
|
||||
|
||||
Success criteria:
|
||||
|
||||
- desired skills are stored in Paperclip
|
||||
- selected skills are copied into the execution workspace for the next run
|
||||
- no persistent installed/stale state is reported from `skills/sync`
|
||||
|
||||
## 6. API Plan
|
||||
|
||||
## 6.1 Keep the current minimal adapter API
|
||||
@@ -349,13 +333,14 @@ Additional UI requirement for shared-home adapters:
|
||||
|
||||
Ship:
|
||||
|
||||
- `cursor`
|
||||
- `cursor_local`
|
||||
- `gemini_local`
|
||||
- `pi_local`
|
||||
|
||||
Status:
|
||||
Rationale:
|
||||
|
||||
- implemented
|
||||
- these are the closest to Codex in architecture
|
||||
- they already inject into stable local skill homes
|
||||
|
||||
### Phase 2: OpenCode shared-home support
|
||||
|
||||
@@ -363,9 +348,10 @@ Ship:
|
||||
|
||||
- `opencode_local`
|
||||
|
||||
Status:
|
||||
Rationale:
|
||||
|
||||
- implemented with shared Claude skills-home warning
|
||||
- technically feasible now
|
||||
- needs slightly more careful product language because of the shared Claude skills home
|
||||
|
||||
### Phase 3: Gateway support decision
|
||||
|
||||
@@ -404,10 +390,10 @@ Adapter-wide skill support is ready when all are true:
|
||||
|
||||
The recommended immediate order is:
|
||||
|
||||
1. `cursor`
|
||||
1. `cursor_local`
|
||||
2. `gemini_local`
|
||||
3. `pi_local`
|
||||
4. `opencode_local`
|
||||
5. defer `openclaw_gateway`
|
||||
|
||||
The local-adapter family now has explicit truth models. The remaining V1 boundary is `openclaw_gateway`, which should stay unsupported until the gateway protocol can report real remote skill state.
|
||||
That gets Paperclip from “skills work for Codex and Claude” to “skills work for the whole local-adapter family,” which is the meaningful V1 milestone.
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Plugin Secret Refs: Company Scope Reintroduction Plan
|
||||
|
||||
Date: 2026-04-26
|
||||
Status: follow-up after fail-closed mitigation
|
||||
Related issue: PAP-2394
|
||||
|
||||
## Current state
|
||||
|
||||
`PAP-2394` now fails closed:
|
||||
|
||||
- `POST /api/plugins/:pluginId/config` rejects any config containing plugin secret refs.
|
||||
- `ctx.secrets.resolve()` is disabled for plugin workers.
|
||||
|
||||
This removes the release-blocking cross-company exposure path, but it also disables plugin secret-ref support until the runtime carries company scope end to end.
|
||||
|
||||
## Vulnerability summary
|
||||
|
||||
The original design mixed an instance-global config store with company-scoped secret bindings:
|
||||
|
||||
- [server/src/routes/plugins.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/routes/plugins.ts:1898) saved one global plugin config row, then wrote bindings into `company_secret_bindings` grouped by each referenced secret's owning company.
|
||||
- [packages/db/src/schema/plugin_config.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/plugin_config.ts:15) stored one config row per plugin, with no company dimension.
|
||||
- [packages/db/src/schema/company_secret_bindings.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/company_secret_bindings.ts:5) already modeled bindings as company-scoped.
|
||||
- [server/src/services/plugin-secrets-handler.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/services/plugin-secrets-handler.ts:212) resolved by `pluginId` + secret UUID, with no active company context from the bridge call.
|
||||
- [packages/plugins/sdk/src/worker-rpc-host.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/plugins/sdk/src/worker-rpc-host.ts:384) exposed `ctx.config.get()` and `ctx.secrets.resolve()` without a company parameter.
|
||||
|
||||
This violated Least Privilege, Complete Mediation, and Secure Defaults.
|
||||
|
||||
## Recommended end state
|
||||
|
||||
Re-enable plugin secret refs only after both of these are true:
|
||||
|
||||
1. Plugin config reads/writes are company-scoped.
|
||||
2. Runtime secret resolution carries explicit company context and enforces it at resolution time.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### 1. Make plugin config company-scoped
|
||||
|
||||
- Add `company_id` to `plugin_config`, with a unique index on `(plugin_id, company_id)`.
|
||||
- Update registry helpers to require `companyId` for `getConfig`, `upsertConfig`, `patchConfig`, and `deleteConfig`.
|
||||
- Update plugin config routes to require `companyId` and call `assertCompanyAccess(req, companyId)`.
|
||||
- Keep instance-global plugin lifecycle state separate from company-scoped plugin config.
|
||||
|
||||
### 2. Propagate company context through the worker runtime
|
||||
|
||||
- Extend the SDK so `ctx.config.get()` and `ctx.secrets.resolve()` can receive or derive `companyId`.
|
||||
- Introduce worker request context storage for handlers that already run with company scope:
|
||||
- `getData`
|
||||
- `performAction`
|
||||
- scoped API routes
|
||||
- tool executions
|
||||
- environment driver calls
|
||||
- Fail closed when plugin code tries to read company-scoped config or secrets outside an active company context.
|
||||
|
||||
### 3. Rebind secrets by `(companyId, pluginId, configPath)`
|
||||
|
||||
- On config save, validate every referenced secret belongs to the authorized company.
|
||||
- Store bindings only for that company.
|
||||
- Resolve secrets only by the current company-scoped binding, never by bare plugin ID plus UUID.
|
||||
- Treat stale bindings as invalid and remove them on config replacement.
|
||||
|
||||
### 4. Prevent cross-company config disclosure
|
||||
|
||||
- When returning config to the UI, only materialize the selected company's secret refs.
|
||||
- Never expose another company's secret UUIDs through the global plugin config surface.
|
||||
|
||||
## Required regression coverage
|
||||
|
||||
- Company A board user cannot save plugin config that references a Company B secret.
|
||||
- Company A plugin execution cannot resolve a Company B secret even if the same plugin is configured for Company B.
|
||||
- Company-scoped config reads only return the selected company's secret bindings.
|
||||
- Config replacement removes stale bindings for the same `(companyId, pluginId)` target.
|
||||
- Runtime calls without company context fail closed.
|
||||
|
||||
## Migration notes
|
||||
|
||||
- Existing `plugin_config` rows need a migration strategy before re-enable.
|
||||
- Safest default: do not auto-assume a company for historical secret refs.
|
||||
- Prefer one of:
|
||||
- explicit admin migration per company, or
|
||||
- import existing rows as non-secret config only and require re-entry of secret refs.
|
||||
|
||||
## Release posture
|
||||
|
||||
- Keep plugin secret refs disabled until all steps above land.
|
||||
- Do not restore the feature behind a soft warning; the insecure path must remain unavailable by default.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Scaled Kanban Board Design
|
||||
|
||||
Date: 2026-05-05
|
||||
Branch: `feat/scaled-kanban-board`
|
||||
|
||||
## Context
|
||||
|
||||
The Issues page currently supports list and board modes. List mode already has grouping, sorting, filtering, nested parent/child rows, deferred row rendering, and incremental render limits. Board mode uses classic status columns with draggable cards. It fetches per-status board data, but the current UI still presents each lane as an unbounded stack of cards, which becomes tall and heavy when a company has hundreds of issues.
|
||||
|
||||
The goal is to keep the Kanban mental model while making high-volume boards usable. This is a UI-first change. It should not introduce schema changes or new API contracts in the first pass.
|
||||
|
||||
## Problem
|
||||
|
||||
When Paperclip has many issues, board columns get too tall and slow. The operator loses the ability to scan the board quickly, and rendering or dragging through long columns becomes unpleasant. The first version should solve this by reducing the number of visible cards per column and by collapsing low-signal columns, not by replacing Kanban with a different inventory surface.
|
||||
|
||||
## Design
|
||||
|
||||
Board mode remains status-column based. Each column shows its total issue count, a bounded set of visible cards, and a local affordance to reveal more cards in that column. The board should keep active workflow lanes expanded by default and collapse cold or noisy lanes once issue volume is high.
|
||||
|
||||
Default high-volume behavior activates when the filtered board has more than 100 issues:
|
||||
|
||||
- Compact cards are used by default.
|
||||
- `backlog`, `done`, and `cancelled` auto-collapse to narrow rails.
|
||||
- `todo`, `in_progress`, `in_review`, and `blocked` remain expanded by default.
|
||||
- Each expanded column renders an initial 10 cards by default.
|
||||
- The user can choose a page size of 10, 25, or 50 cards per column.
|
||||
- The user can reveal one additional page at a time in each column without changing other columns.
|
||||
- Drag and drop continues to work for visible cards.
|
||||
|
||||
The toolbar should expose compact controls for:
|
||||
|
||||
- toggling compact cards
|
||||
- hiding or showing cold lanes
|
||||
- choosing cards per column
|
||||
- resetting board density to defaults
|
||||
|
||||
These preferences should persist through the existing issue view-state/localStorage mechanism and remain scoped by company.
|
||||
|
||||
## Component Shape
|
||||
|
||||
`IssuesList` remains the owner of issue board view state. It should store board-density preferences alongside the existing issue view state, including compact card preference, cold-lane mode, and cards-per-column page size.
|
||||
|
||||
`KanbanBoard` receives board tuning props from `IssuesList` and delegates per-lane display to `KanbanColumn`.
|
||||
|
||||
`KanbanColumn` owns only local presentation mechanics for a lane:
|
||||
|
||||
- whether the lane is rendered as an expanded column or collapsed rail
|
||||
- how many cards are currently visible in that lane
|
||||
- the local "show more" action
|
||||
|
||||
`KanbanCard` gets a compact variant. The compact card should still show the issue identifier, title, live state, priority, and assignee when available, but with tighter spacing and fewer vertical affordances.
|
||||
|
||||
## Data Flow
|
||||
|
||||
The first implementation uses the current issue data already available to board mode. No database, shared type, or route change is required.
|
||||
|
||||
Column totals are computed from the in-memory filtered board issues. If a column reaches the existing remote board query cap, the existing warning remains the truth source that more filtering may be required.
|
||||
|
||||
Future server-side column pagination can be added later if the UI-only version is not enough for very large instances.
|
||||
|
||||
## Error Handling
|
||||
|
||||
This feature should not introduce new network errors. Existing issue loading and update errors continue to surface through the Issues page.
|
||||
|
||||
For drag and drop:
|
||||
|
||||
- Moving a visible card keeps the current optimistic behavior.
|
||||
- Hidden cards remain hidden until revealed.
|
||||
- A collapsed lane rail is a valid drop target. Dropping onto it moves the issue to that status and keeps the lane collapsed.
|
||||
|
||||
## Testing
|
||||
|
||||
Focused tests should cover:
|
||||
|
||||
- board mode passes density preferences into `KanbanBoard`
|
||||
- columns render only the initial visible card count
|
||||
- "show more" reveals more cards in a single column
|
||||
- high-volume cold lanes render as collapsed rails by default
|
||||
- compact cards preserve identifier/title/live/priority/assignee signals
|
||||
- drag/drop status updates still call `onUpdateIssue`
|
||||
|
||||
Manual verification should include opening the Issues board with a large fixture or mocked issue set and confirming that columns remain usable with hundreds of issues.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Server-side per-column pagination
|
||||
- New issue schema fields
|
||||
- Replacing Kanban with a dense table or action-only board
|
||||
- Changing issue status semantics
|
||||
- Broad visual redesign of the Issues page
|
||||
@@ -1,250 +0,0 @@
|
||||
# Scaled Kanban Board Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the Issues Kanban board usable with hundreds of issues by adding compact high-volume rendering, collapsed cold lanes, and per-column reveal controls.
|
||||
|
||||
**Architecture:** Keep the change UI-only. `IssuesList` owns persisted board density preferences in existing company-scoped view state, while `KanbanBoard` owns lane rendering, card density, collapsed rails, and per-column "show more" state.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Vite, Vitest/jsdom, `@dnd-kit/core`, `@dnd-kit/sortable`, Tailwind utility classes.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `ui/src/components/IssuesList.tsx`: extend `IssueViewState`, derive high-volume board preferences, add toolbar controls, pass props into `KanbanBoard`.
|
||||
- Modify `ui/src/components/KanbanBoard.tsx`: add compact cards, collapsed rail lanes, visible-card limits, and per-column reveal behavior.
|
||||
- Create `ui/src/components/KanbanBoard.test.tsx`: focused tests for high-volume behavior and drag/drop update callback.
|
||||
- Modify `ui/src/components/IssuesList.test.tsx`: update the mocked `KanbanBoard` expectations for new props.
|
||||
- Keep `doc/plans/2026-05-05-scaled-kanban-board-design.md` as the design source of truth.
|
||||
|
||||
## Task 1: Add Kanban Board Scaling Mechanics
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/components/KanbanBoard.tsx`
|
||||
- Create: `ui/src/components/KanbanBoard.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write focused tests**
|
||||
|
||||
Create `ui/src/components/KanbanBoard.test.tsx` with tests that render 60 todo issues and assert:
|
||||
|
||||
```tsx
|
||||
renderBoard({ issues: createIssues(60, "todo"), compactCards: true, initialVisibleCount: 10, revealIncrement: 10 });
|
||||
expect(container.textContent).toContain("Showing 10 of 60");
|
||||
expect(container.textContent).toContain("Show 10 more");
|
||||
```
|
||||
|
||||
Also test collapsed rails:
|
||||
|
||||
```tsx
|
||||
renderBoard({ issues: createIssues(3, "done"), collapsedStatuses: ["done"] });
|
||||
expect(container.textContent).toContain("Done");
|
||||
expect(container.textContent).toContain("3");
|
||||
expect(container.textContent).not.toContain("Issue 1");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: fail because `KanbanBoard.test.tsx` is new and the props/behavior do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal board behavior**
|
||||
|
||||
In `KanbanBoard.tsx`, add exported constants:
|
||||
|
||||
```ts
|
||||
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
|
||||
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
|
||||
export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE = 10;
|
||||
export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const;
|
||||
```
|
||||
|
||||
Extend props:
|
||||
|
||||
```ts
|
||||
compactCards?: boolean;
|
||||
collapsedStatuses?: string[];
|
||||
initialVisibleCount?: number;
|
||||
revealIncrement?: number;
|
||||
```
|
||||
|
||||
Add per-status visible-count state keyed by status. Expanded columns render `issues.slice(0, visibleCount)` and show a button when hidden issues remain. Collapsed columns render a narrow droppable rail with status icon, label, and count, but no cards.
|
||||
|
||||
Reset per-status visible-count state when `initialVisibleCount` or `revealIncrement` changes so choosing a smaller cards-per-column preset does not leave a column expanded past the newly selected page size.
|
||||
|
||||
- [ ] **Step 4: Preserve drag/drop**
|
||||
|
||||
Keep `DndContext`, `SortableContext`, and `handleDragEnd` status detection. Because collapsed rails use `useDroppable({ id: status })`, dropping a visible card onto a rail continues to resolve `targetStatus` through the existing status-id branch.
|
||||
|
||||
- [ ] **Step 5: Run focused test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/KanbanBoard.tsx ui/src/components/KanbanBoard.test.tsx
|
||||
git commit -m "Scale kanban board columns"
|
||||
```
|
||||
|
||||
## Task 2: Wire Board Density State Into IssuesList
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/components/IssuesList.tsx`
|
||||
- Modify: `ui/src/components/IssuesList.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write/update tests**
|
||||
|
||||
In `IssuesList.test.tsx`, update the `KanbanBoard` mock to capture:
|
||||
|
||||
```ts
|
||||
compactCards?: boolean;
|
||||
collapsedStatuses?: string[];
|
||||
initialVisibleCount?: number;
|
||||
revealIncrement?: number;
|
||||
```
|
||||
|
||||
Add a test that stores board mode in localStorage, renders more than 100 issues, and expects:
|
||||
|
||||
```ts
|
||||
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
compactCards: true,
|
||||
collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]),
|
||||
initialVisibleCount: 10,
|
||||
revealIncrement: 10,
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/IssuesList.test.tsx
|
||||
```
|
||||
|
||||
Expected: fail because `IssuesList` does not pass the new props yet.
|
||||
|
||||
- [ ] **Step 3: Add persisted board density preferences**
|
||||
|
||||
Extend `IssueViewState`:
|
||||
|
||||
```ts
|
||||
boardCardDensity: "auto" | "compact" | "comfortable";
|
||||
boardColdLaneMode: "auto" | "collapsed" | "expanded";
|
||||
boardColumnPageSize: 10 | 25 | 50;
|
||||
```
|
||||
|
||||
Default the density modes to `"auto"` and page size to `10`. Derive:
|
||||
|
||||
```ts
|
||||
const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD;
|
||||
const boardCompactCards = viewState.boardCardDensity === "compact"
|
||||
|| (viewState.boardCardDensity === "auto" && boardHighVolume);
|
||||
const boardCollapsedStatuses = viewState.boardColdLaneMode === "collapsed"
|
||||
|| (viewState.boardColdLaneMode === "auto" && boardHighVolume)
|
||||
? [...KANBAN_COLD_STATUSES]
|
||||
: [];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add toolbar controls**
|
||||
|
||||
When `viewState.viewMode === "board"`, add small outline/icon buttons near the existing view controls:
|
||||
|
||||
```tsx
|
||||
<Button ... title={boardCompactCards ? "Use comfortable cards" : "Use compact cards"}>...</Button>
|
||||
<Button ... title={boardCollapsedStatuses.length > 0 ? "Expand cold lanes" : "Collapse cold lanes"}>...</Button>
|
||||
<Button ... title="Cards per column">...</Button>
|
||||
<Button ... title="Reset board density">...</Button>
|
||||
```
|
||||
|
||||
Use lucide icons already available or import `ChevronsDownUp`, `PanelTopClose`, and `RotateCcw`.
|
||||
|
||||
- [ ] **Step 5: Pass board props**
|
||||
|
||||
Update the `KanbanBoard` call:
|
||||
|
||||
```tsx
|
||||
<KanbanBoard
|
||||
issues={filtered}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
compactCards={boardCompactCards}
|
||||
collapsedStatuses={boardCollapsedStatuses}
|
||||
initialVisibleCount={viewState.boardColumnPageSize}
|
||||
revealIncrement={viewState.boardColumnPageSize}
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/IssuesList.tsx ui/src/components/IssuesList.test.tsx
|
||||
git commit -m "Wire issue board density controls"
|
||||
```
|
||||
|
||||
## Task 3: Verification And PR Prep
|
||||
|
||||
**Files:**
|
||||
- Verify existing changes only.
|
||||
|
||||
- [ ] **Step 1: Run targeted UI tests**
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 2: Run broader cheap test path**
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 3: Check worktree**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: only intentional changes before committing, or clean after final commit.
|
||||
|
||||
- [ ] **Step 4: Prepare PR**
|
||||
|
||||
Read `.github/PULL_REQUEST_TEMPLATE.md` and use it for the PR body. Include:
|
||||
|
||||
- design spec path
|
||||
- scaled Kanban behavior summary
|
||||
- test commands and results
|
||||
- Model Used section with the current Codex model details available in this session
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: The plan covers compact high-volume board cards, collapsed cold lanes, cards-per-column presets, per-column reveal controls, persisted board preferences, current API reuse, and focused tests.
|
||||
- Placeholder scan: No unresolved markers or unspecified implementation placeholders remain.
|
||||
- Type consistency: The plan consistently uses `boardCardDensity`, `boardColdLaneMode`, `boardColumnPageSize`, `compactCards`, `collapsedStatuses`, `initialVisibleCount`, and `revealIncrement`.
|
||||
@@ -1,135 +0,0 @@
|
||||
# LLM Wiki Paperclip Asset And Work-Product Security Gate
|
||||
|
||||
Status: accepted Phase 5 policy
|
||||
Date: 2026-05-06
|
||||
Owner: Security engineering
|
||||
Scope: Paperclip-derived ingestion into the LLM Wiki before any asset or work-product content indexing ships
|
||||
|
||||
## Decision
|
||||
|
||||
Phase 5 remains **fail-closed** for Paperclip assets and work products.
|
||||
|
||||
- Paperclip-derived **text extraction is allowed only** for issue titles/descriptions, issue comments, and issue documents.
|
||||
- Paperclip **assets/attachments** and **issue work products** are **metadata-only** in Phase 5.
|
||||
- **Linked summaries** and **content extraction** for assets/work products are **not approved** in Phase 5.
|
||||
- No implementation may fetch `/api/assets/:id/content`, dereference a work-product `url`, scrape preview pages, or embed binary/blob content into source bundles or source snapshots.
|
||||
|
||||
This keeps the secure path easier than the insecure one and avoids broadening the wiki into a second content-distribution channel.
|
||||
|
||||
## Allowed Source Kinds
|
||||
|
||||
These source kinds may contribute body text to Paperclip-derived source bundles:
|
||||
|
||||
| Source kind | Allowed body fields | Reason |
|
||||
| --- | --- | --- |
|
||||
| Issue | `title`, `description`, identifier/status metadata | First-party Paperclip text under company ACL |
|
||||
| Comment | `body` | First-party Paperclip text under company ACL |
|
||||
| Document | `body`, `title`, `key`, revision metadata | First-party Paperclip text under company ACL |
|
||||
|
||||
## Assets And Work Products
|
||||
|
||||
### Assets / attachments
|
||||
|
||||
Allowed in Phase 5:
|
||||
|
||||
- metadata-only references built from allowlisted structured fields already stored in Paperclip
|
||||
- recommended fields: `issueId`, `issueCommentId`, `attachmentId`, `assetId`, `originalFilename`, `contentType`, `byteSize`, `sha256`, `createdAt`, `createdByAgentId`, `createdByUserId`
|
||||
|
||||
Disallowed in Phase 5:
|
||||
|
||||
- fetching asset bytes from `/api/assets/:id/content`
|
||||
- parsing any blob body, including `text/plain`, `text/markdown`, `application/json`, images, SVG, PDFs, archives, or office formats
|
||||
- storing `contentPath` in wiki source bundles or source snapshots
|
||||
- model summarization of attachment bodies
|
||||
|
||||
### Work products
|
||||
|
||||
Allowed in Phase 5:
|
||||
|
||||
- metadata-only references built from allowlisted structured fields already stored in Paperclip
|
||||
- recommended fields: `issueId`, `workProductId`, `type`, `provider`, `title`, `status`, `reviewState`, `healthStatus`, `externalId`, `isPrimary`, `createdAt`, `updatedAt`
|
||||
- optional boolean/derived metadata such as `hasUrl: true`
|
||||
|
||||
Disallowed in Phase 5:
|
||||
|
||||
- fetching or crawling the work-product `url`
|
||||
- scraping preview pages, artifacts, pull requests, branches, commits, or custom provider targets through the wiki ingestion path
|
||||
- storing raw `url` values in wiki source bundles or source snapshots
|
||||
- model-authored linked summaries derived from off-record content
|
||||
|
||||
## MIME Allowlists And Size Caps
|
||||
|
||||
No MIME allowlist is approved for asset content extraction in Phase 5 because **no asset body extraction is approved at all**.
|
||||
|
||||
- Every asset MIME type is treated as opaque for Paperclip-derived indexing.
|
||||
- Existing upload limits remain storage concerns, not ingestion approvals.
|
||||
- Work-product destinations are also opaque regardless of MIME type or size.
|
||||
|
||||
Any future issue that wants blob parsing must define:
|
||||
|
||||
- a positive MIME allowlist
|
||||
- per-type parser strategy
|
||||
- per-source size caps
|
||||
- sandbox/isolation requirements
|
||||
- prompt-injection handling
|
||||
- regression tests for refusal paths
|
||||
|
||||
## Redaction Rules
|
||||
|
||||
Metadata-only means **structured facts only**, not capability-bearing links.
|
||||
|
||||
- Do not persist `contentPath` for assets.
|
||||
- Do not persist raw work-product `url` values.
|
||||
- Do not persist query strings, fragments, signed URL tokens, or userinfo.
|
||||
- Prefer stable identifiers (`assetId`, `workProductId`, `externalId`) over links.
|
||||
|
||||
This addresses Sensitive Information Disclosure, Unsafe Consumption of APIs, and Insecure Output Handling risks.
|
||||
|
||||
## Provenance Rules
|
||||
|
||||
Every metadata-only reference must preserve enough provenance to explain where it came from without reading the underlying content:
|
||||
|
||||
- `companyId`
|
||||
- `issueId`
|
||||
- attachment/work-product id
|
||||
- producer identity when available
|
||||
- timestamps
|
||||
- an explicit `metadata_only` marker in any future reference/snapshot schema
|
||||
|
||||
## Review-Required Behavior
|
||||
|
||||
Human review is **not** required for plain metadata-only references that stay inside the allowlisted fields above.
|
||||
|
||||
Human review **is required**, with a separate security sign-off issue, before enabling any of the following:
|
||||
|
||||
- asset body extraction
|
||||
- work-product URL fetching
|
||||
- linked summaries generated from asset/work-product content
|
||||
- storing raw blob links or raw remote URLs in wiki source material
|
||||
- non-default-space routing for Paperclip-derived asset/work-product references
|
||||
|
||||
## Security Rationale
|
||||
|
||||
This gate exists because the current host surfaces have different trust properties:
|
||||
|
||||
- issue/comment/document text is first-party Paperclip content already exposed through company-scoped issue/document APIs
|
||||
- asset content is a blob download surface (`/api/assets/:id/content`) and can carry prompt-injection or parser-risk payloads
|
||||
- work products can point at arbitrary destinations through `url`, which reintroduces SSRF, token leakage, and prompt-injection risk if dereferenced automatically
|
||||
|
||||
Relevant threat classes:
|
||||
|
||||
- OWASP LLM Top 10: Prompt Injection, Sensitive Information Disclosure, Insecure Output Handling, Excessive Agency
|
||||
- OWASP API Top 10: SSRF, Unsafe Consumption of APIs, Broken Object Property Level Authorization
|
||||
- Saltzer & Schroeder: Least Privilege, Fail Securely, Complete Mediation, Secure Defaults
|
||||
|
||||
## Follow-Up Implementation Scope
|
||||
|
||||
A follow-up implementation issue is justified only for **metadata-only references**.
|
||||
|
||||
That implementation must:
|
||||
|
||||
- keep assets/work products out of source-bundle body text
|
||||
- never fetch blob bytes or remote URLs
|
||||
- redact capability-bearing link fields
|
||||
- mark references as `metadata_only`
|
||||
- ship tests proving source bundles/snapshots never contain `contentPath` or raw work-product `url` fields
|
||||
@@ -1,486 +0,0 @@
|
||||
# Skills CLI And Catalog Contract
|
||||
|
||||
Status: Phase A engineering contract
|
||||
Date: 2026-05-26
|
||||
Source plan: approved Paperclip skills CLI and catalog plan
|
||||
|
||||
This document freezes the first implementation contract for the `paperclipai skills`
|
||||
command group and the app-shipped skills catalog. It is intentionally a build
|
||||
contract, not a full product spec.
|
||||
|
||||
## Decisions
|
||||
|
||||
- `paperclipai skills` manages Paperclip company skills. It does not manage
|
||||
local adapter homes directly.
|
||||
- Installing a skill means adding or updating a company-scoped
|
||||
`company_skills` record.
|
||||
- Attaching a skill to an agent is a separate agent desired-state operation.
|
||||
- Adapter runtime sync is a third step handled through adapter skill APIs.
|
||||
- Root `skills/` remains reserved for Paperclip runtime and operational skills.
|
||||
- App-shipped catalog skills live in `packages/skills-catalog`, not root
|
||||
`skills/`.
|
||||
- Catalog skills are inspectable before install. Inspection never mutates company
|
||||
state.
|
||||
- External sources continue to use the existing company skill import API in the
|
||||
first release. No separate marketplace, tap, or source registry is part of this
|
||||
phase.
|
||||
- Agent desired skills continue to live in
|
||||
`adapterConfig.paperclipSkillSync.desiredSkills` for the first release. Do not
|
||||
add a normalized `agent_skills` table unless later implementation evidence
|
||||
requires it.
|
||||
|
||||
## Terms
|
||||
|
||||
- Company skill: a row in `company_skills`, owned by one company.
|
||||
- Catalog skill: an app-shipped skill entry in `@paperclipai/skills-catalog`.
|
||||
- Skill ref: a user-supplied company skill reference. The CLI accepts company
|
||||
skill `id`, canonical `key`, or unique `slug`.
|
||||
- Catalog ref: a user-supplied catalog reference. The CLI accepts catalog `id`,
|
||||
canonical `key`, or unique `slug`.
|
||||
- Desired skills: the skill key set stored on the agent adapter config.
|
||||
- Runtime snapshot: the adapter-reported `AgentSkillSnapshot` for desired,
|
||||
installed, missing, stale, external, required, or unsupported skills.
|
||||
|
||||
## CLI Contract
|
||||
|
||||
All skills commands use the existing client command stack:
|
||||
|
||||
- Global client options: `--data-dir`, `--config`, `--context`, `--profile`,
|
||||
`--api-base`, `--api-key`, and `--json`.
|
||||
- Company-scoped commands also accept `-C, --company-id <id>` and otherwise use
|
||||
`PAPERCLIP_COMPANY_ID` or the active context profile.
|
||||
- Human output goes to stdout. Errors go to stderr.
|
||||
- `--json` prints pretty JSON and no decorative labels.
|
||||
- Successful commands exit `0`. Validation, API, or conflict errors exit `1`.
|
||||
- API errors use the existing `API error <status>: <message>` formatting.
|
||||
- Mutating commands print a short summary in human mode and the raw result in
|
||||
JSON mode.
|
||||
- Commands that can delete or clear state must prompt in a TTY. In non-TTY mode
|
||||
they must require `--yes`.
|
||||
|
||||
### Company Skill Commands
|
||||
|
||||
These commands are Phase B and must work over existing APIs.
|
||||
|
||||
| Command | Behavior | JSON output |
|
||||
|---|---|---|
|
||||
| `skills list` | Lists company skills from `GET /api/companies/:companyId/skills`. Human rows include `id`, `key`, `slug`, `name`, `source`, `trust`, `compatibility`, and `attachedAgents`. | `CompanySkillListItem[]` |
|
||||
| `skills show <skill-ref>` | Resolves `id`, `key`, or unique `slug`, then reads detail. Ambiguous slugs are conflicts. | `CompanySkillDetail` |
|
||||
| `skills file <skill-ref> [--path <path>]` | Resolves the skill, reads a file with default `SKILL.md`, and prints raw file content in human mode. This command must remain pipeable. | `CompanySkillFileDetail` |
|
||||
| `skills import <source>` | Calls existing import API. Source may be a local path, GitHub URL, skills.sh URL or command, `owner/repo`, `owner/repo/skill`, or URL-like source already accepted by the server. | `CompanySkillImportResult` |
|
||||
| `skills create --name <name> [--slug <slug>] [--description <text>] [--body-file <path|->]` | Creates a managed local company skill. If `--body-file` is omitted, the server default body is used. `-` reads markdown from stdin. | `CompanySkill` |
|
||||
| `skills scan-projects [--project-id <id>...] [--workspace-id <id>...]` | Calls project scan. Repeated flags become arrays. With neither flag, scan all accessible project workspaces. | `CompanySkillProjectScanResult` |
|
||||
| `skills check [skill-ref]` | Reads update status for one skill, or for every listed company skill when no ref is provided. Unsupported statuses are shown, not hidden. | `CompanySkillCheckRow[]` |
|
||||
| `skills update <skill-ref>` | Installs the update for one skill through the existing install-update API. | `CompanySkillUpdateRow` |
|
||||
| `skills update --all` | Checks all skills, installs only those with `hasUpdate=true`, and reports skipped unsupported or current skills. | `CompanySkillUpdateRow[]` |
|
||||
| `skills remove <skill-ref> [--yes]` | Deletes one company skill after confirmation. | `CompanySkill` |
|
||||
|
||||
`CompanySkillCheckRow` is a CLI-side shape:
|
||||
|
||||
```ts
|
||||
interface CompanySkillCheckRow {
|
||||
skill: Pick<CompanySkillListItem, "id" | "key" | "slug" | "name">;
|
||||
status: CompanySkillUpdateStatus;
|
||||
}
|
||||
```
|
||||
|
||||
`CompanySkillUpdateRow` is a CLI-side shape:
|
||||
|
||||
```ts
|
||||
interface CompanySkillUpdateRow {
|
||||
skillRef: string;
|
||||
action: "updated" | "skipped" | "failed";
|
||||
skill?: CompanySkill;
|
||||
status?: CompanySkillUpdateStatus;
|
||||
reason?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Agent Skill Commands
|
||||
|
||||
These commands are Phase B and use existing agent skill APIs.
|
||||
|
||||
| Command | Behavior | JSON output |
|
||||
|---|---|---|
|
||||
| `skills agent list <agent-ref>` | Resolves the agent using existing agent reference behavior, then prints the adapter `AgentSkillSnapshot`. Human rows include `key`, `runtimeName`, `desired`, `managed`, `required`, `state`, `origin`, and `detail`. | `AgentSkillSnapshot` |
|
||||
| `skills agent sync <agent-ref> --skill <skill-ref>...` | Replaces the agent's non-required desired skill set with the supplied refs and triggers adapter sync. Required Paperclip skills remain enforced by the server. | `AgentSkillSnapshot` |
|
||||
| `skills agent clear <agent-ref> [--yes]` | Clears non-required desired skills by sending an empty desired list, then returns the adapter snapshot. | `AgentSkillSnapshot` |
|
||||
|
||||
The word `sync` is deliberate: it is a desired-state replacement, not an append.
|
||||
An additive command can be added later if operators need it.
|
||||
|
||||
### Catalog CLI Commands
|
||||
|
||||
These commands are Phase E and depend on the catalog APIs from Phase D.
|
||||
|
||||
| Command | Behavior | JSON output |
|
||||
|---|---|---|
|
||||
| `skills browse [--kind bundled|optional] [--category <slug>] [--query <text>]` | Lists app-shipped catalog skills. Human rows include `id`, `key`, `kind`, `category`, `slug`, `name`, `trust`, and `recommendedForRoles`. | `CatalogSkillListItem[]` |
|
||||
| `skills search <query> [--kind bundled|optional] [--category <slug>]` | Alias for catalog browse with `query`. | `CatalogSkillListItem[]` |
|
||||
| `skills inspect <catalog-ref>` | Shows app-shipped catalog detail and file inventory. Does not mutate company state. | `CatalogSkillDetail` |
|
||||
| `skills install <catalog-ref> [--as <slug>] [--force]` | Installs a catalog skill into a company library. `--as` overrides the company skill slug. `--force` may replace a same-key catalog skill but must not bypass hard validation or dangerous security findings. | `CompanySkillInstallCatalogResult` |
|
||||
|
||||
Catalog commands are for the app-shipped Paperclip catalog only. External GitHub,
|
||||
skills.sh, local path, and URL installs remain under `skills import <source>` in
|
||||
the first release.
|
||||
|
||||
## Catalog Package Contract
|
||||
|
||||
Add a workspace package:
|
||||
|
||||
```text
|
||||
packages/skills-catalog/
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts
|
||||
types.ts
|
||||
catalog/
|
||||
bundled/
|
||||
<category>/
|
||||
<slug>/
|
||||
SKILL.md
|
||||
references/
|
||||
scripts/
|
||||
assets/
|
||||
optional/
|
||||
<category>/
|
||||
<slug>/
|
||||
SKILL.md
|
||||
references/
|
||||
scripts/
|
||||
assets/
|
||||
generated/
|
||||
catalog.json
|
||||
scripts/
|
||||
build-catalog-manifest.ts
|
||||
validate-catalog.ts
|
||||
```
|
||||
|
||||
Package name: `@paperclipai/skills-catalog`.
|
||||
|
||||
The package exports:
|
||||
|
||||
- `catalogManifest`
|
||||
- `catalogSkills`
|
||||
- `resolveCatalogSkillRef(ref)`
|
||||
- `getCatalogSkill(id)`
|
||||
- TypeScript types for every manifest shape
|
||||
|
||||
Server and CLI code must import the generated manifest. They must not crawl
|
||||
arbitrary repository paths at request time.
|
||||
|
||||
## Catalog Manifest
|
||||
|
||||
The generated artifact is `packages/skills-catalog/generated/catalog.json`.
|
||||
It is checked in and regenerated by the package build or validation script.
|
||||
|
||||
```ts
|
||||
interface CatalogManifest {
|
||||
schemaVersion: 1;
|
||||
packageName: "@paperclipai/skills-catalog";
|
||||
packageVersion: string;
|
||||
generatedAt: string;
|
||||
skills: CatalogSkill[];
|
||||
}
|
||||
|
||||
interface CatalogSkill {
|
||||
id: string;
|
||||
key: string;
|
||||
kind: "bundled" | "optional";
|
||||
category: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
entrypoint: "SKILL.md";
|
||||
trustLevel: "markdown_only" | "assets" | "scripts_executables";
|
||||
compatibility: "compatible" | "unknown" | "invalid";
|
||||
defaultInstall: boolean;
|
||||
recommendedForRoles: string[];
|
||||
requires: string[];
|
||||
tags: string[];
|
||||
files: CatalogSkillFile[];
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
interface CatalogSkillFile {
|
||||
path: string;
|
||||
kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other";
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
```
|
||||
|
||||
`id` is path-safe:
|
||||
|
||||
```text
|
||||
paperclipai:<kind>:<category>:<slug>
|
||||
```
|
||||
|
||||
`key` is the canonical company skill key installed into `company_skills`:
|
||||
|
||||
```text
|
||||
paperclipai/<kind>/<category>/<slug>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "paperclipai:bundled:software-development:github-pr-workflow",
|
||||
"key": "paperclipai/bundled/software-development/github-pr-workflow",
|
||||
"kind": "bundled",
|
||||
"category": "software-development",
|
||||
"slug": "github-pr-workflow",
|
||||
"name": "github-pr-workflow",
|
||||
"description": "Prepare pull requests, review responses, and verification notes.",
|
||||
"path": "catalog/bundled/software-development/github-pr-workflow",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": ["engineer"],
|
||||
"requires": [],
|
||||
"tags": ["github", "pull-requests"],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 1200,
|
||||
"sha256": "..."
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:..."
|
||||
}
|
||||
```
|
||||
|
||||
## Catalog Skill Frontmatter
|
||||
|
||||
Each catalog `SKILL.md` must include:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: github-pr-workflow
|
||||
description: Prepare pull requests, review responses, and verification notes.
|
||||
key: paperclipai/bundled/software-development/github-pr-workflow
|
||||
recommendedForRoles:
|
||||
- engineer
|
||||
tags:
|
||||
- github
|
||||
- pull-requests
|
||||
---
|
||||
```
|
||||
|
||||
Optional frontmatter:
|
||||
|
||||
- `slug`
|
||||
- `defaultInstall`
|
||||
- `requires`
|
||||
- `metadata`
|
||||
|
||||
The manifest generator owns `kind`, `category`, `path`, `files`,
|
||||
`trustLevel`, `compatibility`, and `contentHash`.
|
||||
|
||||
## Catalog Validation Rules
|
||||
|
||||
Validation must fail when:
|
||||
|
||||
- A catalog entry is not under `catalog/bundled/<category>/<slug>` or
|
||||
`catalog/optional/<category>/<slug>`.
|
||||
- `SKILL.md` is missing.
|
||||
- `category` or `slug` is not a lowercase URL slug.
|
||||
- `name` or `description` frontmatter is missing or empty.
|
||||
- The frontmatter `key`, when present, does not equal the generated key.
|
||||
- Two catalog entries have the same `id`, `key`, or `slug`.
|
||||
- File inventory includes absolute paths, `..` segments, broken symlinks, or
|
||||
files outside the skill directory.
|
||||
- A file exceeds the package-level size limit chosen by implementation.
|
||||
- A skill marked `compatible` cannot be parsed as Agent Skills markdown.
|
||||
- The generated manifest differs from the checked-in
|
||||
`generated/catalog.json`.
|
||||
|
||||
Trust level is derived from inventory:
|
||||
|
||||
- `scripts_executables` when any file is classified as `script`.
|
||||
- `assets` when any file is classified as `asset` or `other` and no script is
|
||||
present.
|
||||
- `markdown_only` when all files are markdown, references, or `SKILL.md`.
|
||||
|
||||
Validation must report all discovered catalog errors when practical, not just
|
||||
the first one.
|
||||
|
||||
## Catalog API Contract
|
||||
|
||||
Phase D adds read APIs and one company install API.
|
||||
|
||||
```text
|
||||
GET /api/skills/catalog
|
||||
GET /api/skills/catalog/:catalogId
|
||||
GET /api/skills/catalog/:catalogId/files?path=SKILL.md
|
||||
POST /api/companies/:companyId/skills/install-catalog
|
||||
```
|
||||
|
||||
`GET /api/skills/catalog` accepts:
|
||||
|
||||
- `kind=bundled|optional`
|
||||
- `category=<slug>`
|
||||
- `q=<text>`
|
||||
|
||||
`catalogId` is the path-safe manifest `id`. The server should also support
|
||||
resolution by `key` or unique `slug` where the ref is carried in a query or body,
|
||||
but route parameters use `id` to avoid slash handling ambiguity.
|
||||
|
||||
Install request:
|
||||
|
||||
```ts
|
||||
interface CompanySkillInstallCatalogRequest {
|
||||
catalogSkillId: string;
|
||||
slug?: string | null;
|
||||
force?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Install result:
|
||||
|
||||
```ts
|
||||
interface CompanySkillInstallCatalogResult {
|
||||
action: "created" | "updated" | "unchanged";
|
||||
skill: CompanySkill;
|
||||
catalogSkill: CatalogSkill;
|
||||
warnings: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Install behavior:
|
||||
|
||||
- Creates or updates a company skill with `sourceType="catalog"`.
|
||||
- Uses catalog `key` as the company skill canonical key.
|
||||
- Uses catalog `slug` unless `slug` is provided.
|
||||
- Materializes the catalog files into a company-managed skill directory so
|
||||
existing skill file reads continue to work.
|
||||
- Stores provenance in metadata:
|
||||
- `catalogId`
|
||||
- `catalogKey`
|
||||
- `catalogKind`
|
||||
- `catalogCategory`
|
||||
- `catalogPath`
|
||||
- `packageName`
|
||||
- `packageVersion`
|
||||
- `originHash`
|
||||
- `originVersion`
|
||||
- `userModifiedAt`
|
||||
- `updateHoldReason`
|
||||
- Writes activity log entries for install and update.
|
||||
- Returns `409` for duplicate slug/key conflicts that cannot be resolved safely.
|
||||
- Returns `422` for invalid, incompatible, or hard-blocked catalog entries.
|
||||
- `force` may replace a same-key catalog-managed skill. It must not bypass
|
||||
company boundaries, permission checks, hard validation, or hard security
|
||||
findings.
|
||||
|
||||
## Error Semantics
|
||||
|
||||
Use existing HTTP semantics:
|
||||
|
||||
- `400`: invalid CLI arguments, invalid query/body shape, or malformed refs.
|
||||
- `401`: missing or invalid auth.
|
||||
- `403`: authenticated principal lacks access or mutation permission.
|
||||
- `404`: skill, catalog entry, agent, file, company, or source not found.
|
||||
- `409`: ambiguous slug, duplicate key/slug, update conflict, or unsafe overwrite.
|
||||
- `422`: semantic violation such as invalid skill content or unsupported source.
|
||||
- `500`: unexpected server failure.
|
||||
|
||||
CLI messages should name the next useful correction, for example:
|
||||
|
||||
- `Skill slug "review" is ambiguous. Use an id or key.`
|
||||
- `Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set a context profile.`
|
||||
- `Catalog skill contains executable scripts and cannot be force-installed until security review semantics allow it.`
|
||||
|
||||
## Phase Acceptance Criteria
|
||||
|
||||
Phase A is complete when this contract is available in the repo and the issue
|
||||
thread links it.
|
||||
|
||||
Phase B, CLI MVP:
|
||||
|
||||
- `paperclipai skills --help` exposes the Phase B command group.
|
||||
- All Phase B commands work against existing company skills and agent skills
|
||||
APIs without schema or server changes.
|
||||
- Skill refs resolve by id, key, or unique slug.
|
||||
- Human and JSON output are covered by focused CLI tests.
|
||||
- `doc/CLI.md` documents company install vs agent desired sync vs runtime sync.
|
||||
|
||||
Phase C, catalog package:
|
||||
|
||||
- `packages/skills-catalog` is a workspace package.
|
||||
- Build or validation regenerates `generated/catalog.json`.
|
||||
- Validation covers frontmatter, id/key/slug uniqueness, directory shape, file
|
||||
inventory, trust derivation, and stale generated output.
|
||||
- Server and CLI can import the manifest without crawling arbitrary paths.
|
||||
- Root `skills/` is not expanded with the app-shipped catalog.
|
||||
|
||||
Phase D, catalog APIs:
|
||||
|
||||
- Catalog list/detail/file APIs are read-only and covered by tests.
|
||||
- Install-from-catalog creates auditable company-scoped skill records with
|
||||
provenance metadata and materialized files.
|
||||
- Company boundary and mutation permission checks match or exceed existing
|
||||
company skill mutations.
|
||||
- Duplicate and unsafe overwrite behavior is explicit and tested.
|
||||
|
||||
Phase E, catalog CLI:
|
||||
|
||||
- Operators can browse, search, inspect, and install app-shipped catalog skills.
|
||||
- External source behavior remains routed through `skills import`.
|
||||
- Output and errors follow the Phase B CLI conventions.
|
||||
- Catalog install is clearly distinct from agent attach/sync in help and docs.
|
||||
|
||||
Phase F, update/reset/audit:
|
||||
|
||||
- Security review records decisions for origin hash, user modification detection,
|
||||
reset, audit findings, and force behavior.
|
||||
- Implementation follows the review or records explicit deferrals.
|
||||
- Mutating reset/update actions are activity logged.
|
||||
- Tests cover dangerous findings, force behavior, and unchanged/current states.
|
||||
|
||||
Phase G, adapter truth model:
|
||||
|
||||
- Adapter snapshots accurately report `unsupported`, `persistent`, or
|
||||
`ephemeral`.
|
||||
- Desired, missing, installed, stale, external, and required states are tested.
|
||||
- External adapter plugins remain dynamically loaded. No hardcoded plugin imports
|
||||
are added.
|
||||
|
||||
Phase H, UI:
|
||||
|
||||
- The existing Company Skills page is extended rather than replaced.
|
||||
- UX guidance covers Company, Bundled, Optional, and External source views.
|
||||
- Install preview shows source, trust, provenance, update state, and file
|
||||
inventory.
|
||||
- Agent attach/detach states are clear.
|
||||
- Frontend handoff includes screenshots or equivalent browser evidence.
|
||||
|
||||
Phase I, initial skill content:
|
||||
|
||||
- Bundled and optional entries use the finalized frontmatter and category rules.
|
||||
- Skill descriptions are specific enough for browse/search.
|
||||
- No script-bearing skill lands without explicit security review evidence.
|
||||
- Validation fixtures or tests cover representative content.
|
||||
|
||||
Phase J, QA and docs:
|
||||
|
||||
- QA validates CLI, catalog APIs, UI install, agent sync, portability, and adapter
|
||||
snapshots against a dev instance.
|
||||
- Blocking defects are linked as first-class issues.
|
||||
- `doc/CLI.md`, `doc/DEVELOPING.md`, and skill workflow docs match shipped
|
||||
behavior.
|
||||
|
||||
## Deferrals
|
||||
|
||||
- No cloud marketplace.
|
||||
- No user-home tap registry.
|
||||
- No hidden curator or autonomous catalog mutator.
|
||||
- No normalized `agent_skills` table in the first release.
|
||||
- No skill sets or bundles in the first release.
|
||||
- No automatic install of every optional catalog skill.
|
||||
- No replacement of company import/export as the portability path.
|
||||
@@ -1,142 +0,0 @@
|
||||
# Local Plugin Development
|
||||
|
||||
This is the short happy-path guide for developing a Paperclip plugin from a folder on your machine. You will scaffold a plugin, run it in watch mode, install it into a running Paperclip instance from an absolute local path, and edit code with the plugin worker reloading after each rebuild.
|
||||
|
||||
For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines/skills, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
|
||||
|
||||
If your plugin has background-like recurring work, model it as managed resources:
|
||||
declare managed routines plus managed agents/projects/skills, then reconcile those
|
||||
resources in worker actions. This gives operators visible work items, budgets,
|
||||
pause controls, and consistent audits instead of hidden daemon behavior.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+ and `pnpm`.
|
||||
- A local Paperclip checkout you can run from source. Local plugin installs read source from disk, so the running server must be able to see the path you give it.
|
||||
|
||||
## The five steps
|
||||
|
||||
```bash
|
||||
# 1. Start Paperclip locally
|
||||
pnpm paperclipai run
|
||||
|
||||
# 2. Scaffold a plugin outside the Paperclip repo
|
||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
||||
|
||||
# 3. Install dependencies and start the watch build
|
||||
cd ~/dev/paperclip-plugins/hello-plugin
|
||||
pnpm install
|
||||
pnpm dev
|
||||
|
||||
# 4. In another terminal, install the plugin from its absolute path
|
||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
||||
|
||||
# 5. Confirm it loaded
|
||||
paperclipai plugin list
|
||||
paperclipai plugin inspect acme.hello-plugin
|
||||
```
|
||||
|
||||
That's the loop. The rest of this page explains what each step does and what to expect when you edit code.
|
||||
|
||||
### 1. Start Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai run
|
||||
```
|
||||
|
||||
Paperclip listens on `http://127.0.0.1:3100` by default. The CLI talks to that server, so leave it running.
|
||||
|
||||
### 2. Scaffold the plugin
|
||||
|
||||
```bash
|
||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
||||
```
|
||||
|
||||
This creates `~/dev/paperclip-plugins/hello-plugin/` with `src/manifest.ts`, `src/worker.ts`, `src/ui/index.tsx`, an esbuild watch config, a Vitest config, and a snapshot of `@paperclipai/plugin-sdk` from your local Paperclip checkout. You can run the package and tests without publishing anything to npm.
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--template <default|connector|workspace|environment>` — starter shape.
|
||||
- `--category <connector|workspace|automation|ui|environment>` — manifest category.
|
||||
- `--display-name`, `--description`, `--author` — manifest metadata.
|
||||
- `--sdk-path <absolute-path>` — point at a specific `packages/plugins/sdk` checkout if you have more than one.
|
||||
|
||||
When `plugin init` finishes, it prints the next four commands literally. You can copy them.
|
||||
|
||||
### 3. Install dependencies and run the watch build
|
||||
|
||||
```bash
|
||||
cd ~/dev/paperclip-plugins/hello-plugin
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`pnpm dev` runs `esbuild --watch` against the plugin source and emits `dist/manifest.js`, `dist/worker.js`, and `dist/ui/`. Leave it running. Every time you save, esbuild rebuilds the affected output file.
|
||||
|
||||
If your plugin has UI and you want a browser-side dev server with hot module replacement during local UI iteration, run `pnpm dev:ui` in a second terminal. It serves `dist/ui/` on `http://127.0.0.1:4177`. This is optional; Paperclip can load the built UI directly from `dist/ui/` without it.
|
||||
|
||||
### 4. Install from the absolute path
|
||||
|
||||
```bash
|
||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
||||
```
|
||||
|
||||
The CLI auto-detects local paths (anything that looks absolute, starts with `./`, `../`, or `~`, or resolves to an existing folder relative to the current directory) and sends `{ isLocalPath: true }` to `POST /api/plugins/install` with the resolved absolute path. If you want to be explicit, pass `--local`.
|
||||
|
||||
You will see a confirmation like:
|
||||
|
||||
```
|
||||
Installing plugin from local path: /Users/you/dev/paperclip-plugins/hello-plugin
|
||||
✓ Installed acme.hello-plugin v0.1.0 (ready)
|
||||
Local plugin installs run trusted local code from your machine.
|
||||
Keep `pnpm dev` running in /Users/you/dev/paperclip-plugins/hello-plugin;
|
||||
Paperclip watches rebuilt dist output and reloads the plugin worker.
|
||||
```
|
||||
|
||||
Relative paths are resolved against the current working directory, so `paperclipai plugin install .` from inside the plugin folder works too.
|
||||
|
||||
### 5. Inspect
|
||||
|
||||
```bash
|
||||
paperclipai plugin list
|
||||
paperclipai plugin inspect acme.hello-plugin
|
||||
```
|
||||
|
||||
`list` shows plugin key, status, version, and short error. `inspect` prints the same record with the full last error if there is one. Both accept `--json` if you want to script against them.
|
||||
|
||||
## Reload semantics, honestly
|
||||
|
||||
Paperclip watches the on-disk plugin package after a local install. The watcher targets the runtime entrypoints declared in the package's `paperclipPlugin` field (`dist/manifest.js`, `dist/worker.js`, `dist/ui/`).
|
||||
|
||||
What that means in practice:
|
||||
|
||||
- **Worker code:** save a `.ts` file → esbuild rewrites `dist/worker.js` → Paperclip debounces ~500ms and restarts the plugin worker. The next worker call uses the new code. There is no in-process hot module replacement for worker code; it is a worker restart.
|
||||
- **Manifest:** save `src/manifest.ts` → `dist/manifest.js` rewrites → the worker restarts and the host re-reads the manifest.
|
||||
- **Plugin UI:** save a `.tsx` file → esbuild rewrites `dist/ui/` → Paperclip reloads the UI bundle on its next mount. To get HMR during UI iteration, run `pnpm dev:ui` and point at the dev server with `devUiUrl` in your manifest while developing.
|
||||
- **Without `pnpm dev`:** the watcher only fires on `dist/*` changes. If you stop the watch build, source edits do not reach Paperclip. Restart `pnpm dev` (or run `pnpm build` once) before expecting changes.
|
||||
- **`node_modules`, `.git`, `.paperclip-sdk`, and other dotfolders are ignored.** Adding a dependency requires the new code to actually be imported and rebuilt before the worker sees it.
|
||||
|
||||
The server never compiles plugin source for you. The package's own build scripts own that step.
|
||||
|
||||
## Local path plugins vs npm packages
|
||||
|
||||
Both go through the same install endpoint, but they mean different things:
|
||||
|
||||
- **Local path plugins are trusted local code.** Paperclip executes worker code from disk under the same trust boundary as the rest of the running instance. This is meant for developing or operating a plugin against a checkout you control. There is no signature check, no sandboxing of worker code, and no provenance metadata beyond the path. Do not install local-path plugins you did not write.
|
||||
- **npm packages are the deployable artifact.** `paperclipai plugin install @acme/plugin-foo` (optionally `--version 1.2.3`) installs from your configured npm registry, version-pins, and produces an install record that other operators can reproduce. Ship plugins this way.
|
||||
|
||||
When you are done iterating locally, publish the package and reinstall the npm-package form so the install reflects what you will ship.
|
||||
|
||||
## Common things to do next
|
||||
|
||||
- **Restart cleanly:** `paperclipai plugin disable <key>` pauses the plugin without removing it. `paperclipai plugin enable <key>` brings it back. `paperclipai plugin uninstall <key>` removes the install record; add `--force` to also purge plugin state and settings.
|
||||
- **Browse examples:** `paperclipai plugin examples` lists the bundled example plugins that ship with the repo, each with a ready-to-run `paperclipai plugin install <path>` line.
|
||||
- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines/skills, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented.
|
||||
- **Routine-first automation:** If your plugin should produce periodic issue work, prefer managed routines and `ctx.routines.managed` reconciliation over custom process loops or unobserved cron code.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect <key>` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. The Paperclip server log has the full validation error.
|
||||
- **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them.
|
||||
- **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development.
|
||||
- **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells.
|
||||
@@ -4,8 +4,6 @@ This guide describes the current, implemented way to create a Paperclip plugin i
|
||||
|
||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||
|
||||
> **New to plugins?** Start with the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) — it walks the CLI happy path (`plugin init` → `pnpm dev` → `plugin install <path>`) end to end. Come back here for the full manifest surface, worker capabilities, and UI components.
|
||||
|
||||
## Current reality
|
||||
|
||||
- Treat plugin workers and plugin UI as trusted code.
|
||||
@@ -13,24 +11,30 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
||||
- Worker-side host APIs are capability-gated.
|
||||
- Plugin UI is not sandboxed by manifest capabilities.
|
||||
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
||||
- Plugin-managed surfaces are first-class records (agents, projects, routines, and
|
||||
skills) rather than private plugin-only state.
|
||||
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
|
||||
only under `/api/plugins/:pluginId/api/*`.
|
||||
- The host provides a small shared React component kit through
|
||||
`@paperclipai/plugin-sdk/ui`; use it for common Paperclip controls before
|
||||
building custom versions.
|
||||
- There is no host-provided shared React component kit for plugins yet.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the CLI scaffold command:
|
||||
Use the scaffold package:
|
||||
|
||||
```bash
|
||||
paperclipai plugin init @yourscope/plugin-name --output /absolute/path/to/plugin-repos
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||
```
|
||||
|
||||
That creates `<output>/plugin-name/` with:
|
||||
For a plugin that lives outside the Paperclip repo:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That creates a package with:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
@@ -41,13 +45,11 @@ That creates `<output>/plugin-name/` with:
|
||||
|
||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first. Pass `--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk` if you have more than one Paperclip checkout.
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||
|
||||
## Local development workflow
|
||||
## Recommended local workflow
|
||||
|
||||
See the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) for the full happy path (`pnpm dev` → `paperclipai plugin install <absolute-path>` → `paperclipai plugin list`) and reload semantics.
|
||||
|
||||
Minimum verification from the generated plugin folder:
|
||||
From the generated plugin folder:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -56,6 +58,16 @@ pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Supported alpha surface
|
||||
|
||||
Worker:
|
||||
@@ -71,12 +83,10 @@ Worker:
|
||||
- database namespace via `ctx.db`
|
||||
- scoped JSON API routes declared with `apiRoutes`
|
||||
- entities
|
||||
- projects, project workspaces, and plugin-managed projects
|
||||
- projects and project workspaces
|
||||
- companies
|
||||
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
|
||||
- agents, plugin-managed agents, and agent sessions
|
||||
- plugin-managed routines
|
||||
- plugin-managed skills
|
||||
- agents and agent sessions
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
@@ -133,183 +143,6 @@ handler. The worker receives sanitized headers, route params, query, parsed JSON
|
||||
body, actor context, and company id. Do not use plugin routes to claim core
|
||||
paths; they always remain under `/api/plugins/:pluginId/api/*`.
|
||||
|
||||
## Managed Paperclip resources
|
||||
|
||||
Plugins that provide durable Paperclip business objects should declare them in
|
||||
the manifest and let the host create or relink the actual records per company.
|
||||
Do this for plugin-owned agents, projects, routines, and skills.
|
||||
Do not hide long-lived work behind private plugin state when it should be visible
|
||||
to the board, scoped to a company, audited, budgeted, and assigned like normal
|
||||
Paperclip work.
|
||||
|
||||
Content-oriented plugins, such as LLM Wiki-style ingestion or durable knowledge
|
||||
systems, should use the same pattern: managed projects for operation issues,
|
||||
managed agents plus managed skills for LLM work, and managed routines for
|
||||
ingest, lint, refresh, or maintenance runs.
|
||||
|
||||
Use these surfaces:
|
||||
|
||||
- Managed agents: declare top-level `agents[]` and require
|
||||
`agents.managed`. Use this when the plugin provides a named worker the board
|
||||
should see in the org, budget, pause, invoke, and inspect. Managed agents are
|
||||
normal Paperclip agents with plugin ownership metadata, not background plugin
|
||||
workers.
|
||||
- Managed projects: declare top-level `projects[]` and require
|
||||
`projects.managed`. Use this when the plugin needs a stable company-scoped
|
||||
project for its issues, routines, or workspace-oriented UI. Keep plugin work
|
||||
in a project instead of scattering generated issues across unrelated projects.
|
||||
- Managed routines: declare top-level `routines[]` and require
|
||||
`routines.managed`. Use this for scheduled, webhook, or manually triggered
|
||||
jobs that should create visible Paperclip issues. Prefer managed routines over
|
||||
plugin `jobs[]` for recurring business work; plugin jobs are for plugin
|
||||
runtime maintenance that does not need a board-visible task trail.
|
||||
- Managed skills: declare top-level `skills[]` and require `skills.managed`.
|
||||
Use this for reusable plugin capabilities that should be surfaced to operators and
|
||||
synced into Paperclip managed agents.
|
||||
|
||||
Managed resources are resolved by stable plugin keys, not hardcoded database
|
||||
ids. In a worker action or data handler, call `ctx.agents.managed.reconcile()`,
|
||||
`ctx.projects.managed.reconcile()`, `ctx.routines.managed.reconcile()`, and
|
||||
`ctx.skills.managed.reconcile()` for
|
||||
the current `companyId`. `reconcile()` creates the missing resource, relinks a
|
||||
recoverable binding, or returns the existing resource. `reset()` reapplies the
|
||||
manifest defaults when the operator wants to restore the plugin's suggested
|
||||
configuration.
|
||||
|
||||
Declare dependencies between managed resources with refs. A routine can point
|
||||
at a managed agent through `assigneeRef` and at a managed project through
|
||||
`projectRef`. Reconcile the referenced agent and project before reconciling the
|
||||
routine; if a ref is still missing, the routine resolution reports
|
||||
`missing_refs` instead of guessing.
|
||||
|
||||
```ts
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "example.research-plugin",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Research Plugin",
|
||||
description: "Creates a managed research agent and scheduled research routine.",
|
||||
author: "Example",
|
||||
categories: ["automation"],
|
||||
capabilities: [
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"routines.managed",
|
||||
"skills.managed",
|
||||
"instance.settings.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
agentKey: "researcher",
|
||||
displayName: "Researcher",
|
||||
role: "research",
|
||||
title: "Research Agent",
|
||||
capabilities: "Runs recurring research briefs for this company.",
|
||||
adapterPreference: ["codex_local", "claude_local", "process"],
|
||||
instructions: {
|
||||
content: "Follow the Paperclip heartbeat and produce concise research briefs.",
|
||||
},
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
projectKey: "research",
|
||||
displayName: "Research",
|
||||
description: "Recurring research work created by the Research Plugin.",
|
||||
status: "in_progress",
|
||||
},
|
||||
],
|
||||
routines: [
|
||||
{
|
||||
routineKey: "weekly-brief",
|
||||
title: "Weekly research brief",
|
||||
description: "Create a short research brief for the board.",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: "researcher" },
|
||||
projectRef: { resourceKind: "project", resourceKey: "research" },
|
||||
priority: "medium",
|
||||
triggers: [
|
||||
{
|
||||
kind: "schedule",
|
||||
label: "Monday morning",
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "America/Chicago",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
skillKey: "weekly-brief-skills",
|
||||
displayName: "Weekly Briefer",
|
||||
description: "Reusable skill for the managed research workflow.",
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: "settings",
|
||||
displayName: "Research",
|
||||
exportName: "SettingsPage",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
In the worker, expose a small setup action or settings-page action that
|
||||
reconciles the resources for the selected company:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
setup(ctx) {
|
||||
ctx.actions.register("setup-company", async (params) => {
|
||||
const companyId = String(params.companyId ?? "");
|
||||
if (!companyId) throw new Error("companyId is required");
|
||||
|
||||
const project = await ctx.projects.managed.reconcile("research", companyId);
|
||||
const agent = await ctx.agents.managed.reconcile("researcher", companyId);
|
||||
const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId);
|
||||
const skill = await ctx.skills.managed.reconcile("weekly-brief-skills", companyId);
|
||||
|
||||
return { project, agent, routine, skill };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Authoring rules:
|
||||
|
||||
- Keep keys stable once published. Renaming `agentKey`, `projectKey`,
|
||||
`routineKey`, or `skillKey` creates a new managed resource from the host's
|
||||
point of view.
|
||||
- Use managed agents for plugin-provided labor. Use `ctx.agents.invoke()` or
|
||||
`ctx.agents.sessions` only after you have a real agent id, either selected by
|
||||
the operator or resolved from `ctx.agents.managed`.
|
||||
- Use managed routines for recurring or externally triggered work that should
|
||||
produce tasks. Schedule, webhook, and API triggers are visible routine
|
||||
triggers, and each run has the normal Paperclip issue/audit trail.
|
||||
- Use managed skills for reusable operator-visible capabilities that are shared
|
||||
by managed agents. Reconcile skill declarations by `skillKey` and keep the
|
||||
declared skill markdown and files in sync with agent behavior.
|
||||
- Use managed projects to keep plugin-generated work organized and to give
|
||||
project-scoped plugin UI a stable home. For filesystem access inside a
|
||||
project, still resolve project workspaces through `ctx.projects`.
|
||||
- Keep defaults conservative. Managed declarations are suggestions owned by the
|
||||
plugin, but the resulting resources are normal Paperclip records that the
|
||||
operator can inspect, pause, and adjust.
|
||||
|
||||
UI:
|
||||
|
||||
- `usePluginData`
|
||||
@@ -325,7 +158,6 @@ Mount surfaces currently wired in the host include:
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `routeSidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
@@ -336,191 +168,6 @@ Mount surfaces currently wired in the host include:
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
## Shared host components
|
||||
|
||||
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
|
||||
Paperclip-native control. The host owns the implementation, so plugins inherit
|
||||
the board's current styling, ordering, recent selections, and dark-mode behavior
|
||||
without importing `ui/src` internals.
|
||||
|
||||
Prefer shared components for common Paperclip UX patterns to reduce drift and
|
||||
deprecation risk, especially for task/assignment flows and routine or sidebar-like
|
||||
plugin screens.
|
||||
|
||||
Currently exposed components include:
|
||||
|
||||
- `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown.
|
||||
- `FileTree` for serializable file and directory trees.
|
||||
- `IssuesList` for a native company-scoped issue table.
|
||||
- `AssigneePicker` for the same agent/user selector used in the new issue pane.
|
||||
Use the controlled `value` format `agent:<id>`, `user:<id>`, or `""`.
|
||||
- `ProjectPicker` for the same project selector used in the new issue pane.
|
||||
Use the controlled project id value, or `""` for no project.
|
||||
- `ManagedRoutinesList` for plugin-owned routine settings pages.
|
||||
|
||||
```tsx
|
||||
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function PluginAssignmentControls({ companyId }: { companyId: string }) {
|
||||
const [assignee, setAssignee] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssigneePicker
|
||||
companyId={companyId}
|
||||
value={assignee}
|
||||
onChange={(value) => setAssignee(value)}
|
||||
/>
|
||||
<ProjectPicker
|
||||
companyId={companyId}
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## File and path UI
|
||||
|
||||
Plugin UI often needs to render a file tree, accept a folder path, or browse a
|
||||
project workspace. There are three different surfaces for that, and they map to
|
||||
different trust and data-flow boundaries. Pick the surface that matches the
|
||||
data the plugin actually has.
|
||||
|
||||
### When to use the shared `FileTree`
|
||||
|
||||
Use `FileTree` from `@paperclipai/plugin-sdk/ui` whenever the plugin only needs
|
||||
to render a serializable file/directory list and react to selection or
|
||||
expand/collapse. The host owns the implementation, so plugin UI inherits the
|
||||
board's icons, indent, focus ring, and dark-mode styling without importing host
|
||||
internals.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
FileTree,
|
||||
type FileTreeNode,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const nodes: FileTreeNode[] = [
|
||||
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
|
||||
{
|
||||
name: "wiki",
|
||||
path: "wiki",
|
||||
kind: "dir",
|
||||
children: [
|
||||
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function WikiTree() {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["wiki"]));
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={selected}
|
||||
expandedPaths={expanded}
|
||||
onSelectFile={(path) => setSelected(path)}
|
||||
onToggleDir={(path) =>
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
next.has(path) ? next.delete(path) : next.add(path);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Good fits:
|
||||
|
||||
- LLM Wiki page navigation in `packages/plugins/plugin-llm-wiki` builds a
|
||||
`FileTreeNode[]` from worker query results and renders it through `FileTree`.
|
||||
- The example `plugin-file-browser-example` lazily fetches a directory's
|
||||
children through a `loadFileList` action when `onToggleDir` fires, then
|
||||
merges the children into the local tree state — letting the shared component
|
||||
handle rendering and selection.
|
||||
|
||||
Boundary rules:
|
||||
|
||||
- Keep the prop surface serializable (`nodes`, `expandedPaths`, `checkedPaths`,
|
||||
`fileBadges`, `fileTones`). Do not pass arbitrary render functions across the
|
||||
plugin/host boundary in v1; the supported escape hatches are
|
||||
`fileBadges` (status pill keyed by path) and `fileTones` (row tone keyed by
|
||||
path).
|
||||
- Do not import the host's `FileTree.tsx` or any `ui/src/*` module. The SDK
|
||||
declaration is the only supported import path for plugin UI.
|
||||
- The shared `FileTree` is for rendering and selection. Plugin-specific editors,
|
||||
ingest flows, query forms, and lint runs stay inside the plugin and do not
|
||||
belong as `FileTree` props.
|
||||
|
||||
### When to declare `localFolders`
|
||||
|
||||
When the plugin needs operator-configured filesystem roots — typically for
|
||||
trusted local plugins like wiki tooling — declare `localFolders[]` on the
|
||||
manifest and add the `local.folders` capability. The host renders a settings
|
||||
surface for the operator to set the absolute path, validates the path
|
||||
server-side (containment, symlinks, required files/directories), and exposes
|
||||
`ctx.localFolders.readText()` and `ctx.localFolders.writeTextAtomic()` in the
|
||||
worker.
|
||||
|
||||
```ts
|
||||
export const manifest = {
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "pages"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- The data lives outside any project workspace.
|
||||
- Reads and writes need company-scoped configuration.
|
||||
- The operator picks the path once in plugin settings and the worker resolves
|
||||
files relative to that root.
|
||||
|
||||
Do not use `localFolders` to grant the UI direct browser-side access to the
|
||||
filesystem — there is no such capability. The browser still goes through the
|
||||
worker via `getData` / `performAction`, and the worker only exposes paths it
|
||||
chose to expose.
|
||||
|
||||
### When to keep worker-mediated project workspace browsing
|
||||
|
||||
When the data lives inside an existing project workspace, keep the browsing
|
||||
flow worker-mediated:
|
||||
|
||||
- The worker uses `ctx.projects.listWorkspaces()` to resolve the workspace
|
||||
path, then reads its filesystem with normal Node APIs.
|
||||
- The plugin UI calls a `getData` handler for the root listing and an action
|
||||
for lazy children, then renders them through `FileTree`.
|
||||
- The worker is the only side that touches the disk. The browser receives a
|
||||
serializable tree and never sees raw absolute paths it can replay.
|
||||
|
||||
The example `plugin-file-browser-example` is the reference for this pattern:
|
||||
the worker registers `fileList` (data) and `loadFileList` (action) over the
|
||||
same handler, and the UI uses the action for on-toggle directory loading so the
|
||||
shared `FileTree` stays the rendering surface.
|
||||
|
||||
### Mixing surfaces
|
||||
|
||||
A single plugin can use more than one of these. The LLM Wiki uses
|
||||
`localFolders` for its content root, then renders the resulting page list
|
||||
through `FileTree`. The file browser example uses `ctx.projects.listWorkspaces`
|
||||
to pick a workspace and renders its on-disk tree through `FileTree` with lazy
|
||||
loading. Pick the boundary per data source, not per plugin.
|
||||
|
||||
## Company routes
|
||||
|
||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||
|
||||
@@ -27,7 +27,7 @@ Current limitations to keep in mind:
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
- The current runtime ships a small host-provided plugin UI component kit through `@paperclipai/plugin-sdk/ui`, but does not support plugin asset uploads/reads yet. Treat plugin asset APIs as future-scope ideas, not current implementation promises.
|
||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
|
||||
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
|
||||
API routes.
|
||||
@@ -319,10 +319,7 @@ export interface PaperclipPluginManifestV1 {
|
||||
version: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
author: string;
|
||||
categories: Array<"connector" | "workspace" | "automation" | "ui">;
|
||||
minimumHostVersion?: string;
|
||||
/** @deprecated Use `minimumHostVersion` instead. Retained for backwards compatibility. */
|
||||
minimumPaperclipVersion?: string;
|
||||
capabilities: string[];
|
||||
entrypoints: {
|
||||
@@ -338,42 +335,15 @@ export interface PaperclipPluginManifestV1 {
|
||||
description: string;
|
||||
parametersSchema: JsonSchema;
|
||||
}>;
|
||||
database?: PluginDatabaseDeclaration;
|
||||
apiRoutes?: PluginApiRouteDeclaration[];
|
||||
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
|
||||
agents?: PluginManagedAgentDeclaration[];
|
||||
projects?: PluginManagedProjectDeclaration[];
|
||||
routines?: PluginManagedRoutineDeclaration[];
|
||||
skills?: PluginManagedSkillDeclaration[];
|
||||
localFolders?: PluginLocalFolderDeclaration[];
|
||||
/** Legacy top-level launcher declarations. Prefer `ui.launchers` for new manifests. */
|
||||
launchers?: PluginLauncherDeclaration[];
|
||||
ui?: {
|
||||
launchers?: PluginLauncherDeclaration[];
|
||||
slots: Array<{
|
||||
type: "page"
|
||||
| "detailTab"
|
||||
| "taskDetailView"
|
||||
| "dashboardWidget"
|
||||
| "sidebar"
|
||||
| "routeSidebar"
|
||||
| "sidebarPanel"
|
||||
| "projectSidebarItem"
|
||||
| "globalToolbarButton"
|
||||
| "toolbarButton"
|
||||
| "contextMenuItem"
|
||||
| "commentAnnotation"
|
||||
| "commentContextMenuItem"
|
||||
| "settingsPage"
|
||||
| "companySettingsPage";
|
||||
type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage";
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** Which export name in the UI bundle provides this component */
|
||||
exportName: string;
|
||||
/** For detailTab: which entity types this tab appears on */
|
||||
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
|
||||
/** For page and companySettingsPage: single route segment */
|
||||
routePath?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -384,17 +354,10 @@ Rules:
|
||||
- `id` must be globally unique
|
||||
- `id` should normally equal the npm package name
|
||||
- `apiVersion` must match the host-supported plugin API version
|
||||
- `minimumHostVersion` is preferred, with `minimumPaperclipVersion` retained for
|
||||
backwards compatibility
|
||||
- `capabilities` must be static and install-time visible
|
||||
- config schema must be JSON Schema compatible
|
||||
- `entrypoints.ui` points to the directory containing the built UI bundle
|
||||
- `ui.slots` declares which extension slots the plugin fills, so the host knows what to mount without loading the bundle eagerly; each slot references an `exportName` from the UI bundle
|
||||
- declare managed declarations with the matching `*.managed` capability:
|
||||
- `agents` → `agents.managed`
|
||||
- `projects` → `projects.managed`
|
||||
- `routines` → `routines.managed`
|
||||
- `skills` → `skills.managed`
|
||||
|
||||
## 11. Agent Tools
|
||||
|
||||
@@ -668,22 +631,6 @@ Plugins that need filesystem, git, terminal, or process operations handle those
|
||||
|
||||
Trusted orchestration plugins can create and update Paperclip issues through `ctx.issues` instead of importing server internals. The public issue contract includes parent/project/goal links, board or agent assignees, blocker IDs, labels, billing code, request depth, execution workspace inheritance, and plugin origin metadata.
|
||||
|
||||
Plugins that perform durable work should declare managed Paperclip resources rather than using private plugin state:
|
||||
|
||||
- `agents` + `ctx.agents.managed.*` for named, invokable operators (`agents.managed` required)
|
||||
- `projects` + `ctx.projects.managed.*` for stable, scoped issue/workspace ownership (`projects.managed` required)
|
||||
- `routines` + `ctx.routines.managed.*` for schedule/webhook/manual execution with issue trails (`routines.managed` required)
|
||||
- `skills` + `ctx.skills.managed.*` for reusable agent capabilities (`skills.managed` required)
|
||||
|
||||
The LLM Wiki plugin is the current reference for this pattern: it declares managed
|
||||
agents, projects, routines, and skills in manifest, reconciles them per company,
|
||||
and uses managed routines for periodic wiki maintenance and ingest operations.
|
||||
Content-oriented plugins should follow the same model instead of running
|
||||
unmanaged background loops: make the LLM-facing worker an operator-visible
|
||||
managed agent, attach reusable prompt/tool guidance as managed skills, keep
|
||||
operation issues in a managed project, and drive recurring work through managed
|
||||
routines.
|
||||
|
||||
Origin rules:
|
||||
|
||||
- Built-in core issues keep built-in origins such as `manual` and `routine_execution`.
|
||||
@@ -799,38 +746,20 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
|
||||
- `activity.read`
|
||||
- `costs.read`
|
||||
- `issues.orchestration.read`
|
||||
- `database.namespace.read`
|
||||
|
||||
### Data Write
|
||||
|
||||
- `issues.create`
|
||||
- `issues.update`
|
||||
- `issue.comments.create`
|
||||
- `issue.interactions.create`
|
||||
- `issue.documents.write`
|
||||
- `issue.relations.write`
|
||||
- `issues.checkout`
|
||||
- `issues.wakeup`
|
||||
- `assets.write`
|
||||
- `assets.read`
|
||||
- `activity.log.write`
|
||||
- `metrics.write`
|
||||
- `telemetry.track`
|
||||
- `assets.read`
|
||||
- `assets.write`
|
||||
- `database.namespace.migrate`
|
||||
- `database.namespace.write`
|
||||
- `goals.create`
|
||||
- `goals.update`
|
||||
- `projects.managed`
|
||||
- `routines.managed`
|
||||
- `skills.managed`
|
||||
- `agents.managed`
|
||||
- `agents.pause`
|
||||
- `agents.resume`
|
||||
- `agents.invoke`
|
||||
- `agent.sessions.create`
|
||||
- `agent.sessions.list`
|
||||
- `agent.sessions.send`
|
||||
- `agent.sessions.close`
|
||||
|
||||
### Plugin State
|
||||
|
||||
@@ -843,10 +772,8 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
|
||||
- `events.emit`
|
||||
- `jobs.schedule`
|
||||
- `webhooks.receive`
|
||||
- `local.folders`
|
||||
- `http.outbound`
|
||||
- `secrets.read-ref`
|
||||
- `environment.drivers.register`
|
||||
|
||||
### Agent Tools
|
||||
|
||||
@@ -859,7 +786,6 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
|
||||
- `ui.page.register`
|
||||
- `ui.detailTab.register`
|
||||
- `ui.dashboardWidget.register`
|
||||
- `ui.commentAnnotation.register`
|
||||
- `ui.action.register`
|
||||
|
||||
## 15.2 Forbidden Capabilities
|
||||
@@ -968,7 +894,6 @@ Job rules:
|
||||
3. The host prevents overlapping execution of the same plugin/job combination unless explicitly allowed later.
|
||||
4. Every job run is recorded in Postgres.
|
||||
5. Failed jobs are retryable.
|
||||
6. For recurring business workflows that should create visible Paperclip work, prefer managed routines and managed resources over jobs. Jobs remain useful for private plugin-runtime maintenance tasks.
|
||||
|
||||
## 18. Webhooks
|
||||
|
||||
@@ -1051,23 +976,13 @@ export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
|
||||
The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides:
|
||||
|
||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`, `useHostNavigation()`
|
||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`
|
||||
- **Design tokens**: colors, spacing, typography, shadows matching the host theme
|
||||
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
|
||||
- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps`
|
||||
|
||||
Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
|
||||
|
||||
`useHostNavigation()` is the supported way for plugin UI to navigate to
|
||||
Paperclip-internal pages. It exposes `resolveHref(to)`, `navigate(to,
|
||||
options?)`, and `linkProps(to, options?)`. Plugin links should prefer
|
||||
`linkProps()` so anchors keep real `href` values for copy-link, modifier-click,
|
||||
middle-click, and open-in-new-tab behavior while plain left-clicks route through
|
||||
the host SPA router. The host resolves company-scoped paths against the active
|
||||
company prefix without double-prefixing already-prefixed paths. Plugin UI should
|
||||
not use raw same-origin `href`s or `window.location.assign()` for internal
|
||||
Paperclip navigation because those can force a full document reload.
|
||||
|
||||
### 19.0.2 Bundle Isolation
|
||||
|
||||
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
|
||||
@@ -1147,11 +1062,6 @@ The host SDK ships shared components that plugins can import to quickly build UI
|
||||
| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
|
||||
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
|
||||
| `Spinner` | Loading indicator | Data fetch states |
|
||||
| `FileTree` | Host-styled file/directory tree | Wiki pages, workspace files, import previews |
|
||||
| `IssuesList` | Host issue list | Plugin pages that need a native issue view |
|
||||
| `AssigneePicker` | Host assignee picker for agents and board users | Creating issues, assigning routines, filtering work |
|
||||
| `ProjectPicker` | Host project picker | Creating issues, scoping dashboards, filtering work |
|
||||
| `ManagedRoutinesList` | Host routine list | Plugin settings pages that manage routines |
|
||||
|
||||
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
|
||||
|
||||
@@ -1209,8 +1119,6 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th
|
||||
|
||||
Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards.
|
||||
|
||||
For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations.
|
||||
|
||||
## 20. Local Tooling
|
||||
|
||||
Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations.
|
||||
@@ -1460,14 +1368,6 @@ Each plugin may expose a company-context main page:
|
||||
|
||||
This page is where board users do most day-to-day work.
|
||||
|
||||
## 24.4 Company Settings Plugin Page
|
||||
|
||||
Each ready plugin may expose a company settings page:
|
||||
|
||||
- `/:companyPrefix/company/settings/:routePath`
|
||||
|
||||
The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages.
|
||||
|
||||
## 25. Uninstall And Data Lifecycle
|
||||
|
||||
When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
|
||||
|
||||
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 89 KiB |
@@ -6,7 +6,7 @@ Date: 2026-04-13
|
||||
This document maps the current invite creation and acceptance states implemented in:
|
||||
|
||||
- `ui/src/pages/CompanyInvites.tsx`
|
||||
- `ui/src/components/NewAgentDialog.tsx`
|
||||
- `ui/src/pages/CompanySettings.tsx`
|
||||
- `ui/src/pages/InviteLanding.tsx`
|
||||
- `server/src/routes/access.ts`
|
||||
- `server/src/lib/join-request-dedupe.ts`
|
||||
@@ -23,16 +23,16 @@ This document maps the current invite creation and acceptance states implemented
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Board[Board user on invite or add-agent screen]
|
||||
Board[Board user on invite screen]
|
||||
HumanInvite[Create human company invite]
|
||||
AgentInvite[Generate agent onboarding prompt]
|
||||
OpenClawInvite[Generate OpenClaw invite prompt]
|
||||
Active[Invite state: active]
|
||||
Revoked[Invite state: revoked]
|
||||
Expired[Invite state: expired]
|
||||
Accepted[Invite state: accepted]
|
||||
BootstrapDone[Bootstrap accepted<br/>no join request]
|
||||
HumanReuse{Matching human join request<br/>already exists for same user/email?}
|
||||
HumanPending[Legacy human join request<br/>pending_approval]
|
||||
HumanPending[Join request<br/>pending_approval]
|
||||
HumanApproved[Join request<br/>approved]
|
||||
HumanRejected[Join request<br/>rejected]
|
||||
AgentPending[Agent join request<br/>pending_approval<br/>+ optional claim secret]
|
||||
@@ -44,7 +44,7 @@ flowchart TD
|
||||
OpenClawReplay[Special replay path:<br/>accepted invite can be POSTed again<br/>for openclaw_gateway only]
|
||||
|
||||
Board --> HumanInvite --> Active
|
||||
Board --> AgentInvite --> Active
|
||||
Board --> OpenClawInvite --> Active
|
||||
Active --> Revoked: revoke
|
||||
Active --> Expired: expiresAt passes
|
||||
|
||||
@@ -52,10 +52,12 @@ flowchart TD
|
||||
BootstrapDone --> Accepted
|
||||
|
||||
Active --> HumanReuse: human accept
|
||||
HumanReuse --> HumanApproved: reuse existing pending/approved request<br/>ensure active membership
|
||||
HumanReuse --> HumanApproved: no reusable request<br/>create and approve request
|
||||
HumanPending --> HumanApproved: same invitee replays accepted invite<br/>or board approves legacy request
|
||||
HumanReuse --> HumanPending: reuse existing pending request
|
||||
HumanReuse --> HumanApproved: reuse existing approved request
|
||||
HumanReuse --> HumanPending: no reusable request<br/>create new request
|
||||
HumanPending --> HumanApproved: board approves
|
||||
HumanPending --> HumanRejected: board rejects
|
||||
HumanPending --> Accepted
|
||||
HumanApproved --> Accepted
|
||||
|
||||
Active --> AgentPending: agent accept
|
||||
@@ -100,10 +102,10 @@ stateDiagram-v2
|
||||
LatestInviteVisible --> Ready: navigate away or refresh
|
||||
}
|
||||
|
||||
CompanySelection --> AgentPromptReady: Add-agent modal prompt generator
|
||||
AgentPromptReady --> AgentPromptPending: Generate agent onboarding prompt
|
||||
AgentPromptPending --> AgentSnippetVisible: prompt generated
|
||||
AgentPromptPending --> AgentPromptReady: generation failed
|
||||
CompanySelection --> OpenClawPromptReady: Company settings prompt generator
|
||||
OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt
|
||||
OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated
|
||||
OpenClawPromptPending --> OpenClawPromptReady: generation failed
|
||||
```
|
||||
|
||||
## Invite Landing Screen States
|
||||
@@ -148,8 +150,7 @@ stateDiagram-v2
|
||||
|
||||
state AcceptedInviteSummary {
|
||||
[*] --> SummaryBranch
|
||||
SummaryBranch --> AcceptPending: human joinRequestStatus=pending_approval/approved<br/>and membership missing
|
||||
SummaryBranch --> PendingApprovalReload: agent joinRequestStatus=pending_approval
|
||||
SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval
|
||||
SummaryBranch --> OpeningCompany: joinRequestStatus=approved<br/>and human invite user is now a member
|
||||
SummaryBranch --> RejectedReload: joinRequestStatus=rejected
|
||||
SummaryBranch --> ConsumedReload: approved agent invite or other consumed state
|
||||
@@ -176,7 +177,6 @@ sequenceDiagram
|
||||
participant Landing as Invite landing UI
|
||||
participant Auth as Auth session
|
||||
participant Join as join_requests table
|
||||
participant Membership as company_memberships + grants
|
||||
|
||||
Board->>Settings: Choose role and click Create invite
|
||||
Settings->>API: POST /api/companies/:companyId/invites
|
||||
@@ -197,19 +197,15 @@ sequenceDiagram
|
||||
API->>Join: Look for reusable human join request
|
||||
alt Reusable pending or approved request exists
|
||||
API->>Invites: Mark invite accepted
|
||||
API->>Membership: Ensure active membership and role grants
|
||||
API->>Join: Mark join request approved if needed
|
||||
API-->>Landing: approved join request
|
||||
API-->>Landing: Existing join request status
|
||||
else No reusable request exists
|
||||
API->>Invites: Mark invite accepted
|
||||
API->>Join: Insert pending_approval join request
|
||||
API->>Membership: Ensure active membership and role grants
|
||||
API->>Join: Mark join request approved
|
||||
API-->>Landing: approved join request
|
||||
API-->>Landing: New pending_approval join request
|
||||
end
|
||||
```
|
||||
|
||||
### Legacy Human Reload And Repair Path
|
||||
### Human Approval And Reload Path
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -218,6 +214,8 @@ sequenceDiagram
|
||||
participant Landing as Invite landing UI
|
||||
participant API as Access routes
|
||||
participant Join as join_requests table
|
||||
actor Approver as Company admin
|
||||
participant Queue as Access queue UI
|
||||
participant Membership as company_memberships + grants
|
||||
|
||||
Invitee->>Landing: Reload consumed invite URL
|
||||
@@ -225,15 +223,20 @@ sequenceDiagram
|
||||
API->>Join: Load join request by inviteId
|
||||
API-->>Landing: joinRequestStatus + joinRequestType
|
||||
|
||||
alt human joinRequestStatus = pending_approval or approved but membership missing
|
||||
Landing->>API: POST /api/invites/:token/accept (requestType=human)
|
||||
API->>Membership: Ensure active membership and role grants
|
||||
API->>Join: Mark join request approved if needed
|
||||
alt joinRequestStatus = pending_approval
|
||||
Landing-->>Invitee: Show waiting-for-approval panel
|
||||
Approver->>Queue: Review request in Company Settings -> Access
|
||||
Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve
|
||||
API->>Membership: Ensure membership and grants
|
||||
API->>Join: Mark join request approved
|
||||
Invitee->>Landing: Refresh after approval
|
||||
Landing->>API: GET /api/invites/:token
|
||||
API->>Join: Reload approved join request
|
||||
API-->>Landing: approved status
|
||||
Landing-->>Invitee: Opening company and redirect
|
||||
else joinRequestStatus = rejected
|
||||
Landing-->>Invitee: Show rejected error panel
|
||||
else agent invite or unavailable consumed state
|
||||
else joinRequestStatus = approved but membership missing
|
||||
Landing-->>Invitee: Fall through to consumed/unavailable state
|
||||
end
|
||||
```
|
||||
@@ -244,21 +247,21 @@ sequenceDiagram
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Board as Board user
|
||||
participant AddAgent as Add agent modal
|
||||
participant Settings as Company Settings UI
|
||||
participant API as Access routes
|
||||
participant Invites as invites table
|
||||
actor Gateway as External agent
|
||||
actor Gateway as OpenClaw gateway agent
|
||||
participant Join as join_requests table
|
||||
actor Approver as Company admin
|
||||
participant Agents as agents table
|
||||
participant Keys as agent_api_keys table
|
||||
|
||||
Board->>AddAgent: Generate agent onboarding prompt
|
||||
AddAgent->>API: POST /api/companies/:companyId/invites (allowedJoinTypes=agent)
|
||||
Board->>Settings: Generate OpenClaw invite prompt
|
||||
Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt
|
||||
API->>Invites: Insert active agent invite
|
||||
API-->>AddAgent: Prompt text + invite token
|
||||
API-->>Settings: Prompt text + invite token
|
||||
|
||||
Gateway->>API: POST /api/invites/:token/accept (agent, adapter-specific payload)
|
||||
Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway)
|
||||
API->>Invites: Mark invite accepted
|
||||
API->>Join: Insert pending_approval join request + claimSecretHash
|
||||
API-->>Gateway: requestId + claimSecret + claimApiKeyPath
|
||||
@@ -283,15 +286,14 @@ sequenceDiagram
|
||||
## Notes
|
||||
|
||||
- `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`.
|
||||
- Human acceptance consumes the invite, creates or reuses the matching human join request, immediately marks it `approved`, and ensures an active company membership with the invite's selected role/grants.
|
||||
- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email.
|
||||
- The landing page has two layers of post-accept UI:
|
||||
- immediate mutation-result UI from `POST /api/invites/:token/accept`
|
||||
- reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed
|
||||
- Reload behavior for accepted company invites is now status-sensitive:
|
||||
- human `pending_approval` or `approved` states replay acceptance for the same signed-in user/email so legacy consumed invites can repair missing membership
|
||||
- agent `pending_approval` re-renders the waiting-for-approval panel
|
||||
- `pending_approval` re-renders the waiting-for-approval panel
|
||||
- `rejected` renders the "This join request was not approved." error panel
|
||||
- `approved` becomes a success path for human invites after membership is visible to the current session
|
||||
- `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state
|
||||
- `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`.
|
||||
- Accepted-invite replay is supported for matching human invitees to repair/complete membership, and for `agent` requests with `adapterType=openclaw_gateway` when the existing join request is still `pending_approval` or already `approved`.
|
||||
- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`.
|
||||
- `bootstrap_ceo` invites are one-time and do not create join requests.
|
||||
|
||||
@@ -249,23 +249,6 @@ Make Paperclip skills discoverable to your agent runtime without writing to the
|
||||
3. **Acceptable: env var** — point a skills path env var at the repo's `skills/` directory
|
||||
4. **Last resort: prompt injection** — include skill content in the prompt template
|
||||
|
||||
## Cross-run workspace persistence (no-remote-git contract)
|
||||
|
||||
The local execution-workspace cwd is the **only** persistence boundary across runs. No adapter may depend on a git remote for cross-run state.
|
||||
|
||||
The supported round-trip:
|
||||
|
||||
- **Per-run, on the remote side.** `prepareWorkspaceForSshExecution` (in `packages/adapter-utils/src/ssh.ts`) git-bundles the local worktree and ships it to the run's remote dir. No `git remote` is set anywhere; the bundle is the transport.
|
||||
- **End-of-run, in the adapter's `finally` block.** The adapter invokes `restoreRemoteWorkspace` (e.g. claude-local's `execute.ts`), which calls `restoreWorkspaceFromSshExecution` → `exportGitWorkspaceFromSsh` → `integrateImportedGitHead`. Remote commits made during the run land back in the local Mac worktree with no `git push` and no remote configured.
|
||||
|
||||
The invariant adapters must preserve:
|
||||
|
||||
- **Never `git push`** from adapter or runtime code. Operator-supplied configuration may opt in, but the default contract is no remote operations.
|
||||
- **Never assume a remote exists.** The local cwd is the source of truth between runs.
|
||||
- **Surface restore failures.** A failed sync-back must propagate as a run-level error, not a silent warning. The heartbeat records a `workspace_finalize` row (`succeeded`/`failed`) around `adapter.execute` so dependent issues do not wake on a stale worktree.
|
||||
|
||||
The invariant is pinned by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`: it asserts `git remote` is empty before and after the round-trip and that a remote-only commit still lands locally via restore alone.
|
||||
|
||||
## Security
|
||||
|
||||
- Treat agent output as untrusted (parse defensively, never execute)
|
||||
|
||||
@@ -75,28 +75,11 @@ Fields:
|
||||
```
|
||||
PATCH /api/routines/{routineId}
|
||||
{
|
||||
"status": "paused",
|
||||
"baseRevisionId": "{latestRevisionId}"
|
||||
"status": "paused"
|
||||
}
|
||||
```
|
||||
|
||||
All fields from create are updatable. `baseRevisionId` is optional for backward compatibility; when provided, stale values return `409 Conflict` with the current revision id. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.**
|
||||
|
||||
## List Revisions
|
||||
|
||||
```
|
||||
GET /api/routines/{routineId}/revisions
|
||||
```
|
||||
|
||||
Returns append-only routine definition revisions newest first. Snapshots include routine fields and safe trigger metadata only; webhook secret values and `secretId` are never returned.
|
||||
|
||||
## Restore Revision
|
||||
|
||||
```
|
||||
POST /api/routines/{routineId}/revisions/{revisionId}/restore
|
||||
```
|
||||
|
||||
Restores a historical routine definition by creating a new latest revision copied from the selected revision. Historical revision rows, routine run history, and activity history are preserved. If restoring a deleted webhook trigger requires recreating it, the response can include one-time replacement secret material for that trigger.
|
||||
All fields from create are updatable. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.**
|
||||
|
||||
## Add Trigger
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
title: Secrets Remote Import
|
||||
summary: AWS Secrets Manager metadata-only remote import API
|
||||
---
|
||||
|
||||
Remote import lets the board link existing AWS Secrets Manager entries as
|
||||
Paperclip `external_reference` secrets without copying plaintext into
|
||||
Paperclip.
|
||||
|
||||
Both routes are board-only and company-scoped. The selected provider vault must
|
||||
belong to the company, use `aws_secrets_manager`, and have a selectable status
|
||||
(`ready` or `warning`). Disabled, coming-soon, or cross-company vaults are
|
||||
rejected.
|
||||
|
||||
Remote import is an inventory and metadata workflow. Preview calls AWS
|
||||
`ListSecrets` only and import stores a Paperclip external reference plus
|
||||
fingerprint/version metadata. Neither route calls `GetSecretValue` or
|
||||
`BatchGetSecretValue`, requests `SecretString`, requires KMS decrypt, logs raw
|
||||
remote metadata, or copies secret plaintext into Paperclip.
|
||||
|
||||
## Preview Remote AWS Secrets
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import/preview
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"query": "stripe",
|
||||
"nextToken": "optional-provider-page-token",
|
||||
"pageSize": 50
|
||||
}
|
||||
```
|
||||
|
||||
`query` is optional and is sent to AWS as an inventory filter. Treat it as
|
||||
non-secret metadata because AWS may record list request parameters in
|
||||
CloudTrail. `nextToken` is an opaque AWS cursor; pass it back unchanged.
|
||||
`pageSize` is capped at 100.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"nextToken": null,
|
||||
"candidates": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"remoteName": "prod/stripe",
|
||||
"name": "prod/stripe",
|
||||
"key": "prod-stripe",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||
"hasDescription": true
|
||||
},
|
||||
"status": "ready",
|
||||
"importable": true,
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Candidate `status` values:
|
||||
|
||||
- `ready`: no existing exact external reference and no name/key collision.
|
||||
- `duplicate`: an existing secret already has the exact provider `externalRef`.
|
||||
- `conflict`: the suggested Paperclip `name` or `key` is already in use.
|
||||
|
||||
Conflict `type` values are `exact_reference`, `name`, `key`, and
|
||||
`provider_guardrail`. AWS refs under Paperclip's own managed namespace are
|
||||
blocked as external references so one company cannot import another company's
|
||||
Paperclip-managed AWS secret through a broad runtime role.
|
||||
|
||||
## Import Remote AWS Secret References
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"secrets": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"description": "Stripe key used by production checkout",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||
"hasDescription": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The import response is row-level. Ready rows become active
|
||||
`external_reference` secrets with version metadata only. Exact-reference
|
||||
duplicates and name/key conflicts are skipped without failing the whole request.
|
||||
The `secrets` array accepts 1-100 rows, and the backend re-checks duplicates and
|
||||
conflicts at submit time.
|
||||
Each row may include an optional Paperclip `description` entered during review;
|
||||
blank descriptions are stored as `null`. AWS provider descriptions are not
|
||||
copied into this field.
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"importedCount": 1,
|
||||
"skippedCount": 1,
|
||||
"errorCount": 0,
|
||||
"results": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"status": "imported",
|
||||
"reason": null,
|
||||
"secretId": "<paperclip-secret-id>",
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Activity logs record aggregate counts and provider/vault ids only, not remote
|
||||
secret names, ARNs, tags, or values.
|
||||
|
||||
Imported references may still fail during a future bound runtime resolution if
|
||||
the Paperclip runtime role can list the AWS secret but lacks
|
||||
`secretsmanager:GetSecretValue` or required KMS decrypt permission for that
|
||||
specific secret.
|
||||
@@ -25,357 +25,16 @@ POST /api/companies/{companyId}/secrets
|
||||
|
||||
The value is encrypted at rest. Only the secret ID and metadata are returned.
|
||||
|
||||
To link a provider-owned secret without copying the value into Paperclip, create
|
||||
an external-reference secret:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "prod-stripe-key",
|
||||
"provider": "aws_secrets_manager",
|
||||
"managedMode": "external_reference",
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe",
|
||||
"providerVersionRef": "version-id-or-label"
|
||||
}
|
||||
```
|
||||
|
||||
Paperclip stores the provider reference and a non-sensitive fingerprint only.
|
||||
The value is resolved, when the provider is configured, through the server
|
||||
runtime path that enforces binding context and records access events.
|
||||
|
||||
## Provider Health
|
||||
## Update Secret
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/secret-providers/health
|
||||
```
|
||||
|
||||
Returns provider setup diagnostics, warnings, and local backup guidance. Health
|
||||
responses must not include secret values or provider credentials.
|
||||
|
||||
For `aws_secrets_manager`, an unready health response names the missing
|
||||
non-secret provider environment variables, the AWS SDK default credential source
|
||||
expected by the server runtime, and the custody rule that AWS bootstrap
|
||||
credentials must not be stored in Paperclip `company_secrets`.
|
||||
|
||||
The equivalent CLI check is:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets doctor --company-id {companyId}
|
||||
```
|
||||
|
||||
## Provider Vaults
|
||||
|
||||
Provider vaults are named, company-scoped configurations that route secret
|
||||
material to one of the supported provider backends. See the
|
||||
[secrets deploy guide](/deploy/secrets#provider-vaults) for the operator model
|
||||
and custody rules.
|
||||
|
||||
All routes below require board auth and company access. Mutating routes emit
|
||||
`secret_provider_config.*` activity-log entries. No route in this surface
|
||||
returns provider credential values; submitting credential-shaped fields in
|
||||
`config` is rejected at validation time.
|
||||
|
||||
### List Vaults
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/secret-provider-configs
|
||||
```
|
||||
|
||||
Returns every vault for the company (including disabled rows for audit), each
|
||||
with id, provider, displayName, status, isDefault, non-sensitive `config`,
|
||||
latest health snapshot (`healthStatus`, `healthCheckedAt`, `healthMessage`,
|
||||
`healthDetails`), `disabledAt`, and audit columns.
|
||||
|
||||
### Create Vault
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secret-provider-configs
|
||||
{
|
||||
"provider": "aws_secrets_manager",
|
||||
"displayName": "Prod US-East",
|
||||
"isDefault": true,
|
||||
"config": {
|
||||
"region": "us-east-1",
|
||||
"namespace": "paperclip",
|
||||
"secretNamePrefix": "paperclip",
|
||||
"kmsKeyId": "arn:aws:kms:us-east-1:123456789012:key/abcd-...",
|
||||
"environmentTag": "production"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-provider `config` shapes:
|
||||
|
||||
- `local_encrypted`: optional `backupReminderAcknowledged: boolean`.
|
||||
- `aws_secrets_manager`: required `region`; optional `namespace`,
|
||||
`secretNamePrefix`, `kmsKeyId`, `ownerTag`, `environmentTag`.
|
||||
- `gcp_secret_manager` (coming soon): optional `projectId`, `location`,
|
||||
`namespace`, `secretNamePrefix`.
|
||||
- `vault` (coming soon): optional origin-only HTTPS `address`, `namespace`,
|
||||
`mountPath`, `secretPathPrefix`. `address` values with embedded credentials,
|
||||
paths, query strings, or fragments are rejected.
|
||||
|
||||
`status` defaults to `ready` for `local_encrypted` and `aws_secrets_manager`,
|
||||
and to `coming_soon` for `gcp_secret_manager` and `vault`. Coming-soon and
|
||||
disabled vaults cannot be marked `isDefault`. Setting `isDefault: true` clears
|
||||
the previous default for the same provider in the same transaction.
|
||||
|
||||
### Get Vault
|
||||
|
||||
```
|
||||
GET /api/secret-provider-configs/{id}
|
||||
```
|
||||
|
||||
### Update Vault
|
||||
|
||||
```
|
||||
PATCH /api/secret-provider-configs/{id}
|
||||
{
|
||||
"displayName": "Prod US-East-2",
|
||||
"config": {
|
||||
"region": "us-east-2",
|
||||
"kmsKeyId": "arn:aws:kms:us-east-2:123456789012:key/abcd-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`config` is replaced wholesale on update — pass the full provider config
|
||||
payload, not a partial diff. Status transitions for `gcp_secret_manager` and
|
||||
`vault` are constrained to `coming_soon` and `disabled` until their runtime
|
||||
modules ship.
|
||||
|
||||
### Disable Vault
|
||||
|
||||
```
|
||||
DELETE /api/secret-provider-configs/{id}
|
||||
```
|
||||
|
||||
Soft-deletes the vault: status flips to `disabled`, `isDefault` clears, and
|
||||
`disabledAt` is stamped. Disabled vaults remain in `GET` results for audit
|
||||
purposes but are no longer offered in the secret create/rotate flow.
|
||||
|
||||
### Set Default
|
||||
|
||||
```
|
||||
POST /api/secret-provider-configs/{id}/default
|
||||
```
|
||||
|
||||
Marks the target vault as the default for its provider family and clears the
|
||||
previous default. Returns 422 when the target is `coming_soon` or `disabled`.
|
||||
|
||||
### Run Health Check
|
||||
|
||||
```
|
||||
POST /api/secret-provider-configs/{id}/health
|
||||
```
|
||||
|
||||
Runs a provider-specific health probe and persists the result on the vault.
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"configId": "<uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"status": "ready" | "warning" | "error" | "coming_soon" | "disabled",
|
||||
"message": "Provider vault is ready to handle managed writes",
|
||||
"details": {
|
||||
"code": "provider_ready",
|
||||
"message": "...",
|
||||
"guidance": ["..."]
|
||||
},
|
||||
"checkedAt": "2026-05-06T14:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Health responses never include provider credentials or secret values. For AWS
|
||||
vaults, `details.guidance` may include missing non-secret env names and the
|
||||
expected AWS SDK credential source; coming-soon vaults always return
|
||||
`status: "coming_soon"` with `code: "runtime_locked"` and never call into
|
||||
provider modules.
|
||||
|
||||
### Selecting A Vault When Creating Or Rotating Secrets
|
||||
|
||||
`POST /api/companies/{companyId}/secrets` and
|
||||
`POST /api/secrets/{secretId}/rotate` both accept an optional
|
||||
`providerConfigId` field that pins the secret to a specific vault. When
|
||||
omitted (or null), the operation runs through the deployment-level provider
|
||||
configuration — the same path existing installs already use. The board UI
|
||||
preselects the company's default vault for the chosen provider before
|
||||
submitting, so callers should usually send an explicit `providerConfigId`.
|
||||
Coming-soon and disabled vaults are rejected with a 422; a vault that does not
|
||||
match the secret's provider is rejected the same way.
|
||||
|
||||
```json
|
||||
POST /api/companies/{companyId}/secrets
|
||||
{
|
||||
"name": "prod-stripe-key",
|
||||
"provider": "aws_secrets_manager",
|
||||
"providerConfigId": "<vault-uuid>",
|
||||
"managedMode": "external_reference",
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Redaction Rules
|
||||
|
||||
Every route in this surface enforces the same redaction contract:
|
||||
|
||||
- Secret values are never returned. The board UI never has a "reveal value"
|
||||
affordance; resolution happens server-side at runtime under a binding.
|
||||
- Provider credential values are never accepted, stored, returned, logged, or
|
||||
echoed in error messages. Submitting credential-shaped fields fails
|
||||
validation with a non-leaking error.
|
||||
- Activity log entries record vault id, provider, displayName, status, and
|
||||
isDefault transitions — never `config` payloads or health detail bodies.
|
||||
|
||||
## Remote Import From AWS Secrets Manager
|
||||
|
||||
Remote import links existing AWS Secrets Manager entries into Paperclip as
|
||||
`external_reference` secrets. Import stores provider reference metadata only; it
|
||||
does not copy the remote secret plaintext into Paperclip.
|
||||
|
||||
The routes are board-only and company-scoped. `providerConfigId` must point to
|
||||
a same-company AWS provider vault with status `ready` or `warning`. Disabled,
|
||||
coming-soon, non-AWS, and cross-company vaults are rejected. Imported secrets
|
||||
resolve later through the selected vault, so runtime reads still need
|
||||
`secretsmanager:GetSecretValue` and any required KMS decrypt permission on the
|
||||
selected external secret.
|
||||
|
||||
### Preview Remote Import Candidates
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import/preview
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"query": "stripe",
|
||||
"nextToken": "opaque-provider-token",
|
||||
"pageSize": 50
|
||||
}
|
||||
```
|
||||
|
||||
`query` is optional and is passed to AWS Secrets Manager inventory filtering.
|
||||
Treat it as non-secret metadata because AWS may record list request parameters
|
||||
in CloudTrail. `nextToken` is an opaque AWS cursor; callers must pass it back
|
||||
unchanged and must not synthesize offsets. `pageSize` is optional, defaults to
|
||||
50 in the UI, and is capped at 100.
|
||||
|
||||
Preview uses AWS `ListSecrets` only. It must not call `GetSecretValue` or
|
||||
`BatchGetSecretValue`, must not request `SecretString`, and must not require KMS
|
||||
decrypt. The response contains sanitized metadata for display and conflict
|
||||
decisions:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"nextToken": null,
|
||||
"candidates": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"remoteName": "prod/stripe",
|
||||
"name": "prod/stripe",
|
||||
"key": "prod-stripe",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"createdDate": "2026-05-06T00:00:00.000Z",
|
||||
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||
"hasDescription": true,
|
||||
"hasKmsKey": true,
|
||||
"tagCount": 3
|
||||
},
|
||||
"status": "ready",
|
||||
"importable": true,
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Candidate statuses:
|
||||
|
||||
- `ready`: the row can be selected for import.
|
||||
- `duplicate`: a Paperclip secret already links the same canonical provider
|
||||
reference for the same provider vault.
|
||||
- `conflict`: the row has a name/key collision or provider guardrail failure.
|
||||
|
||||
Conflict types are `exact_reference`, `name`, `key`, and
|
||||
`provider_guardrail`. AWS refs under Paperclip's own managed namespace are
|
||||
blocked as external references; use the Paperclip-managed secret flow for those
|
||||
resources instead.
|
||||
|
||||
### Import Selected Remote References
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"secrets": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"description": "Stripe key used by production checkout",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"createdDate": "2026-05-06T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `secrets` array accepts 1-100 rows. Each row may override the suggested
|
||||
Paperclip `name`, `key`, optional Paperclip `description`,
|
||||
`providerVersionRef`, and sanitized `providerMetadata`. Blank descriptions are
|
||||
stored as `null`; AWS provider descriptions are not copied into Paperclip
|
||||
descriptions. The backend re-checks duplicate refs and name/key conflicts at
|
||||
submit time; a stale preview does not bypass those checks.
|
||||
|
||||
The import response is row-level:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"importedCount": 1,
|
||||
"skippedCount": 1,
|
||||
"errorCount": 0,
|
||||
"results": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"status": "imported",
|
||||
"reason": null,
|
||||
"secretId": "<paperclip-secret-id>",
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Row statuses:
|
||||
|
||||
- `imported`: Paperclip created an active `external_reference` secret and one
|
||||
metadata-only version row.
|
||||
- `skipped`: the row had an exact-reference duplicate or name/key conflict.
|
||||
- `error`: the provider rejected the reference or the row failed validation.
|
||||
|
||||
Activity logs for preview/import store aggregate counts, provider id, and vault
|
||||
id only. They must not store remote secret names, ARNs, descriptions, tags,
|
||||
plaintext values, provider credentials, or raw AWS error blobs.
|
||||
|
||||
## Rotate Secret
|
||||
|
||||
```
|
||||
POST /api/secrets/{secretId}/rotate
|
||||
PATCH /api/secrets/{secretId}
|
||||
{
|
||||
"value": "sk-ant-new-value..."
|
||||
}
|
||||
```
|
||||
|
||||
Creates a new version of the secret. Agents referencing `"version": "latest"`
|
||||
automatically get the new value on next heartbeat. Pin to a specific version
|
||||
when a bad `latest` rollout would affect many agents at once.
|
||||
Creates a new version of the secret. Agents referencing `"version": "latest"` automatically get the new value on next heartbeat.
|
||||
|
||||
## Using Secrets in Agent Config
|
||||
|
||||
@@ -393,20 +52,4 @@ Reference secrets in agent adapter config instead of inline values:
|
||||
}
|
||||
```
|
||||
|
||||
The server resolves and decrypts secret references at runtime, injecting the
|
||||
real value into the agent process environment. Paperclip's custody guarantees
|
||||
end at injection: the agent process can read, log, or forward the value, so
|
||||
treat any secret bound to an agent as exposed to that agent. See the custody
|
||||
boundaries note in the [secrets deploy guide](/deploy/secrets#custody-boundaries).
|
||||
|
||||
## Portability
|
||||
|
||||
Company export/import APIs represent agent and project environment requirements
|
||||
as declarations in the package manifest. Exports omit secret values, secret IDs,
|
||||
provider references, and encrypted provider material. Use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id {companyId}
|
||||
```
|
||||
|
||||
to inspect the declarations that an export would emit before moving a package.
|
||||
The server resolves and decrypts secret references at runtime, injecting the real value into the agent process environment.
|
||||
|
||||
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 321 KiB |
@@ -63,29 +63,6 @@ pnpm paperclipai agent list
|
||||
pnpm paperclipai agent get <agent-id>
|
||||
```
|
||||
|
||||
## Skills Commands
|
||||
|
||||
```sh
|
||||
# Browse app-shipped catalog skills without changing company state
|
||||
pnpm paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
|
||||
pnpm paperclipai skills search "pull request" [--json]
|
||||
|
||||
# Inspect catalog metadata and file inventory before install
|
||||
pnpm paperclipai skills inspect github-pr-workflow
|
||||
|
||||
# Install a catalog skill into the company skill library
|
||||
# This does not attach the skill to any agent.
|
||||
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
|
||||
pnpm paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
|
||||
|
||||
# External sources still use import instead of catalog install
|
||||
pnpm paperclipai skills import ./skills/my-skill --company-id <company-id>
|
||||
pnpm paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
|
||||
|
||||
# Attach desired company skills to an agent after install/import
|
||||
pnpm paperclipai skills agent sync <agent-id> --skill github-pr-workflow --company-id <company-id>
|
||||
```
|
||||
|
||||
## Approval Commands
|
||||
|
||||
```sh
|
||||
|
||||
@@ -57,16 +57,6 @@ pnpm paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||
export PAPERCLIP_API_KEY=...
|
||||
```
|
||||
|
||||
Secret operations are available under `paperclipai secrets`:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
```
|
||||
|
||||
Context is stored at `~/.paperclip/context.json`.
|
||||
|
||||
## Command Categories
|
||||
|
||||
@@ -67,8 +67,7 @@ Validates:
|
||||
|
||||
- Server configuration
|
||||
- Database connectivity
|
||||
- Secrets adapter configuration, including AWS Secrets Manager non-secret env
|
||||
config when selected
|
||||
- Secrets adapter configuration
|
||||
- Storage configuration
|
||||
- Missing key files
|
||||
|
||||
@@ -82,13 +81,6 @@ pnpm paperclipai configure --section secrets
|
||||
pnpm paperclipai configure --section storage
|
||||
```
|
||||
|
||||
`--section secrets` updates the deployment-level provider used as the fallback
|
||||
for secrets that do not target a specific company vault. Per-company provider
|
||||
vaults (named instances, default vault selection, multiple vaults per provider,
|
||||
coming-soon GCP/Vault) live in the board UI under
|
||||
`Company Settings → Secrets → Provider vaults` and the
|
||||
`/api/companies/{companyId}/secret-provider-configs` API.
|
||||
|
||||
## `paperclipai env`
|
||||
|
||||
Show resolved environment configuration:
|
||||
|
||||
@@ -5,52 +5,6 @@ summary: Master key, encryption, and strict mode
|
||||
|
||||
Paperclip encrypts secrets at rest using a local master key. Agent environment variables that contain sensitive values (API keys, tokens) are stored as encrypted secret references.
|
||||
|
||||
## Custody Boundaries
|
||||
|
||||
Paperclip protects secret values up to the moment they are handed to an agent
|
||||
or workload:
|
||||
|
||||
- Storage: values are encrypted at rest by the active provider. The local
|
||||
provider keeps them encrypted with a key that never leaves the host.
|
||||
- Transport: values are decrypted server-side and injected into the agent
|
||||
process environment, SSH command env, sandbox driver, or HTTP request
|
||||
immediately before the call. Paperclip does not return decrypted values to
|
||||
the board UI.
|
||||
- Audit: each resolution records a non-sensitive event (secret id, version,
|
||||
provider id, consumer, outcome) without the value or provider credentials.
|
||||
|
||||
Once a value reaches the consuming process, Paperclip can no longer guarantee
|
||||
secrecy. The agent (or sandbox, or remote host) can read the value, write it to
|
||||
its own logs or transcript, or pass it to downstream tools. Treat any secret
|
||||
you bind to an agent as exposed to that agent. Limit blast radius with bindings
|
||||
(only bind what each agent needs), short-lived provider credentials where the
|
||||
provider supports them, and rotation when an agent transcript or downstream
|
||||
system might have captured a value.
|
||||
|
||||
## Using Secrets In Runs
|
||||
|
||||
Creating a company secret does not automatically create an environment variable.
|
||||
You use a secret by binding it into an agent, project, environment, or plugin
|
||||
configuration field that supports secret references.
|
||||
|
||||
For agent and project environment variables:
|
||||
|
||||
1. Create or link the secret in `Company Settings > Secrets`.
|
||||
2. Open the agent's `Environment variables` field, or the project's `Env`
|
||||
field.
|
||||
3. Add the environment variable key the process expects, such as `GH_TOKEN` or
|
||||
`OPENAI_API_KEY`.
|
||||
4. Set the row source to `Secret`, select the stored secret, and choose either
|
||||
`latest` or a pinned version.
|
||||
|
||||
At runtime, Paperclip resolves the selected secret server-side and injects the
|
||||
resolved value under the env key from the binding row. The stored secret name
|
||||
can be human-readable; the binding key is what the agent process receives.
|
||||
|
||||
Project env applies to every issue run in that project. When a project env key
|
||||
matches an agent env key, the project value wins before Paperclip injects its
|
||||
own `PAPERCLIP_*` runtime variables.
|
||||
|
||||
## Default Provider: `local_encrypted`
|
||||
|
||||
Secrets are encrypted with a local master key stored at:
|
||||
@@ -60,13 +14,6 @@ Secrets are encrypted with a local master key stored at:
|
||||
```
|
||||
|
||||
This key is auto-created during onboarding. The key never leaves your machine.
|
||||
Paperclip best-effort enforces `0600` permissions when it creates or loads the
|
||||
key file. `paperclipai doctor` and the provider health API warn when the file is
|
||||
readable by group or other users.
|
||||
|
||||
Back up the key file together with database backups. A database backup without
|
||||
the key cannot decrypt local secrets, and a key backup without the database
|
||||
metadata is not enough to restore named secret versions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -88,7 +35,6 @@ Validate secrets config:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai doctor
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
```
|
||||
|
||||
### Environment Overrides
|
||||
@@ -109,279 +55,15 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||
|
||||
Recommended for any deployment beyond local trusted.
|
||||
|
||||
Authenticated deployments default strict mode on unless explicitly overridden by
|
||||
configuration or `PAPERCLIP_SECRETS_STRICT_MODE=false`.
|
||||
|
||||
## External References
|
||||
|
||||
Provider-owned secrets can be linked without copying values into Paperclip by
|
||||
using `managedMode: "external_reference"` plus a provider `externalRef`.
|
||||
Paperclip stores metadata and a non-sensitive fingerprint, never the value.
|
||||
Runtime resolution remains server-side and binding-enforced.
|
||||
|
||||
The built-in AWS, GCP, and Vault provider IDs currently accept external
|
||||
reference metadata, but runtime resolution requires provider configuration in the
|
||||
deployment. Their provider health check reports this as a warning until
|
||||
configured.
|
||||
|
||||
For hosted Paperclip Cloud on AWS, see the AWS Secrets Manager operational
|
||||
contract — required env vars, IAM/KMS scoping, naming and tag conventions, and
|
||||
backup/rotation/incident runbooks — in `doc/SECRETS-AWS-PROVIDER.md`.
|
||||
|
||||
## Provider Vaults
|
||||
|
||||
A *provider vault* is a named, company-scoped configuration that points secret
|
||||
material at one of the supported provider backends. Each company can configure
|
||||
multiple vaults, including more than one vault per provider family, and pick a
|
||||
default vault per family for new secret operations. Existing secrets created
|
||||
before any vault was configured continue to resolve through the deployment-level
|
||||
default provider — no migration is required.
|
||||
|
||||
### Where to configure
|
||||
|
||||
Open `Company Settings → Secrets` in the board UI and switch to the
|
||||
`Provider vaults` tab. From there you can:
|
||||
|
||||
- Create a vault for any supported provider family.
|
||||
- Edit the non-secret config of an existing vault.
|
||||
- Set one ready vault per provider family as the company default.
|
||||
- Disable a vault (a soft delete that keeps audit history).
|
||||
- Run a health check against a vault and read the latest result inline.
|
||||
|
||||
The same operations are exposed under
|
||||
`/api/companies/{companyId}/secret-provider-configs` for automation. See the
|
||||
[secrets API reference](/api/secrets#provider-vaults) for the full route table.
|
||||
|
||||
### Custody Of Provider Credentials
|
||||
|
||||
Provider vaults intentionally store only **non-sensitive** configuration:
|
||||
region, project id, namespace, prefix, KMS key id, mount path, address, and
|
||||
similar routing metadata. The API, UI, and activity log never accept, return,
|
||||
or display provider credential values. Submitting fields with names like
|
||||
`accessKeyId`, `secretAccessKey`, `token`, `password`, `serviceAccountJson`,
|
||||
`privateKey`, `keyFile`, `unsealKey`, or any common credential alias is rejected
|
||||
at validation time.
|
||||
|
||||
That keeps the bootstrap rule from the AWS provider applicable to every
|
||||
provider family: **provider credentials live in deployment infrastructure
|
||||
identity, not in Paperclip company secrets**. Allowed credential sources are
|
||||
workload identity attached to the Paperclip server (instance profile, IRSA, ECS
|
||||
task role), `AWS_PROFILE` / SSO / shared config for local runs, an orchestrator
|
||||
secret store that boots the server, or short-lived shell credentials for local
|
||||
development. Do not paste long-lived API keys into the vault config.
|
||||
|
||||
### Vault Status
|
||||
|
||||
Each vault carries a status that drives what the runtime can do with it:
|
||||
|
||||
| Status | Meaning |
|
||||
|---------------|-----------------------------------------------------------------------------------------------|
|
||||
| `ready` | Selectable for create/rotate/resolve. Eligible to be the default. |
|
||||
| `warning` | Saved config exists but health needs attention (for example missing AWS env). Still selectable. |
|
||||
| `coming_soon` | Visible and editable as draft metadata, but locked out of all runtime operations. |
|
||||
| `disabled` | Soft-deleted. Hidden from the secret create/rotate flow. |
|
||||
|
||||
`gcp_secret_manager` and `vault` are pinned to `coming_soon` until their
|
||||
runtime modules ship. The settings UI lets you save draft configuration for
|
||||
those providers (and surfaces them on the vault list), but secret create,
|
||||
rotate, and resolve calls that target a coming-soon vault fail with a clear
|
||||
runtime-locked error.
|
||||
|
||||
### Default Vault Behavior
|
||||
|
||||
A company can mark **one** ready (or warning) vault per provider family as the
|
||||
default. The secret create and rotate dialogs preselect the default vault for
|
||||
the chosen provider so operators don't have to remember which vault to pick.
|
||||
Coming-soon and disabled vaults cannot be marked default; attempting to do so
|
||||
returns a validation error. Setting a new default automatically clears the
|
||||
previous default for that provider.
|
||||
|
||||
If a secret is created without any `providerConfigId` (no vaults exist yet, or
|
||||
the operator clears the selector), runtime resolution falls back to the
|
||||
deployment-level provider configuration — the same path existing installs use.
|
||||
This keeps secrets created before any provider vault was configured working
|
||||
without migration. Picking the default in the UI is an explicit selection, not
|
||||
a runtime fallback: the create call still sends an explicit `providerConfigId`.
|
||||
|
||||
### Multiple Vaults Per Provider
|
||||
|
||||
Multiple vaults from the same provider family are first-class. Common patterns:
|
||||
|
||||
- Two AWS vaults pointing at different regions or KMS keys for environment
|
||||
separation.
|
||||
- A staging Vault address alongside a production address.
|
||||
- A dedicated GCP project for a single product line while the rest of the
|
||||
company uses another.
|
||||
|
||||
Each vault has its own display name, status, default flag, and health record.
|
||||
Operators choose the vault explicitly when creating or rotating a secret; the
|
||||
default vault is preselected to avoid accidental routing to the wrong account.
|
||||
|
||||
### Per-Vault Health Checks
|
||||
|
||||
`POST /api/secret-provider-configs/{id}/health` runs a provider-specific health
|
||||
probe and stores the result on the vault row. The settings UI exposes the same
|
||||
action and renders the result inline. Health responses include a status,
|
||||
operator-facing message, and structured guidance (such as missing env var
|
||||
names, expected credential sources, and backup reminders). They never include
|
||||
provider credentials or secret values. Coming-soon vaults always return a
|
||||
`runtime_locked` health code and never call into provider modules.
|
||||
|
||||
### Provider-Specific Notes
|
||||
|
||||
**Local encrypted vaults** wrap the existing `local_encrypted` provider. The
|
||||
master key path and rotation guidance described above still applies. A local
|
||||
vault config is mostly bookkeeping plus an explicit acknowledgement that the
|
||||
key file is backed up alongside the database.
|
||||
|
||||
**AWS Secrets Manager vaults** read the per-vault `region`, `namespace`,
|
||||
`secretNamePrefix`, `kmsKeyId`, `ownerTag`, and `environmentTag` to route
|
||||
managed writes and external-reference reads. The vault config supplements (and
|
||||
can override) the deployment-level `PAPERCLIP_SECRETS_AWS_*` env. Bootstrap
|
||||
credentials still come from the AWS SDK default credential chain — see
|
||||
`doc/SECRETS-AWS-PROVIDER.md` for the full IAM and KMS contract.
|
||||
|
||||
**GCP Secret Manager** and **HashiCorp Vault** vaults are coming soon. You can
|
||||
save draft `projectId`, `location`, `namespace`, `address`, and `mountPath`
|
||||
metadata so the company is ready to flip them on when the provider modules
|
||||
ship. Vault `address` values must be origin-only `http(s)://host[:port]` URLs;
|
||||
addresses with embedded credentials, paths, query strings, or fragments are
|
||||
rejected.
|
||||
|
||||
### Remote Import From AWS Vaults
|
||||
|
||||
AWS provider vaults can import existing AWS Secrets Manager entries as
|
||||
Paperclip `external_reference` secrets. This is a metadata-only link: Paperclip
|
||||
stores the AWS ARN/path, a fingerprint/version reference, and binding metadata.
|
||||
It does not read, copy, store, log, or display the remote plaintext secret
|
||||
value during preview or import.
|
||||
|
||||
Operator flow in the board UI:
|
||||
|
||||
1. Open `Company Settings -> Secrets`.
|
||||
2. Confirm at least one AWS provider vault is `ready` or `warning`.
|
||||
3. In the `Secrets` tab, choose `Import from vault`.
|
||||
4. Select an AWS vault, search the remote inventory, and load more pages as
|
||||
needed.
|
||||
5. Check the rows to import, review/edit the Paperclip name and key, then
|
||||
submit.
|
||||
6. Review the result summary for created, skipped, and failed rows.
|
||||
|
||||
The preview list is intentionally paged and search-first. AWS accounts can have
|
||||
large per-Region inventories, and `ListSecrets` returns opaque `NextToken`
|
||||
cursors. Do not expect Paperclip to crawl a whole account in the background;
|
||||
load pages deliberately and retry throttled requests with backoff.
|
||||
|
||||
Remote import exposes AWS secret metadata visible to the Paperclip runtime
|
||||
role, including names/ARNs and safe derived fields such as dates, whether a
|
||||
description or KMS key exists, and tag count. Treat names, ARNs, tags, and
|
||||
search text as operational metadata that may be sensitive. The API and activity
|
||||
log must not store raw descriptions, tags, plaintext values, provider
|
||||
credentials, or raw AWS error blobs.
|
||||
|
||||
Required AWS posture:
|
||||
|
||||
- Preview needs optional `secretsmanager:ListSecrets` permission on
|
||||
`Resource: "*"`. AWS does not support constraining `ListSecrets` to
|
||||
individual secret ARNs or tags as an IAM boundary.
|
||||
- Preview/import must not call `secretsmanager:GetSecretValue`,
|
||||
`secretsmanager:BatchGetSecretValue`, or KMS decrypt.
|
||||
- Runtime resolution of an imported reference still needs
|
||||
`secretsmanager:GetSecretValue` on the selected external ARN/path and KMS
|
||||
decrypt when that secret uses a customer-managed key.
|
||||
- Keep managed create/rotate/delete permissions scoped to the Paperclip
|
||||
deployment prefix. Do not broaden managed write/delete permissions just
|
||||
because import inventory is enabled.
|
||||
|
||||
Safe scoping comes from deployment posture rather than AWS list filtering:
|
||||
dedicated Paperclip runtime roles per environment/account, AWS vaults pointed at
|
||||
the intended account and Region, import-enabled roles only where inventory
|
||||
exposure is acceptable, and board-only access to the import routes. Tags and
|
||||
name filters are search aids, not a permission model.
|
||||
|
||||
If import preview fails:
|
||||
|
||||
- `AccessDenied` or `not authorized`: the runtime role is missing
|
||||
`secretsmanager:ListSecrets`; add the optional inventory statement only if
|
||||
remote import should be enabled for that vault.
|
||||
- Throttling: retry after a short delay and narrow the search before loading
|
||||
more pages.
|
||||
- Invalid cursor: refresh the preview; AWS `NextToken` values are opaque and
|
||||
can expire or become stale.
|
||||
- Runtime resolution failure after import: verify `GetSecretValue` and KMS
|
||||
decrypt scope for the selected external secret. Being visible in inventory is
|
||||
not proof that the runtime role can read the value.
|
||||
|
||||
### Backup And Restore
|
||||
|
||||
Each provider family has a different backup story:
|
||||
|
||||
- `local_encrypted`: back up the local master key file and the Paperclip
|
||||
database together. Either alone is not enough to restore the encrypted
|
||||
values, and the vault row only records the path and acknowledgement, not the
|
||||
key bytes.
|
||||
- `aws_secrets_manager`: back up Paperclip's database for vault metadata
|
||||
(vault id, region, prefix, KMS key id, default flag, bindings, version
|
||||
pointers). The actual secret values live in AWS Secrets Manager under the
|
||||
configured prefix; restore by pointing the same Paperclip company at the
|
||||
same AWS namespace and confirming the runtime role still has
|
||||
`GetSecretValue` plus KMS decrypt. The full restore checklist lives in
|
||||
`doc/SECRETS-AWS-PROVIDER.md`.
|
||||
- `gcp_secret_manager` and `vault`: while these are coming soon, only the
|
||||
draft vault config exists in Paperclip. Database backups capture it. There
|
||||
is nothing to restore on the provider side until runtime support lands.
|
||||
|
||||
### AWS Provider Bootstrap Boundary
|
||||
|
||||
The AWS Secrets Manager provider cannot bootstrap itself from Paperclip
|
||||
`company_secrets`. Its initial AWS access must be present before the server can
|
||||
create or resolve AWS-backed company secrets, regardless of whether you use the
|
||||
deployment-level default or a per-company vault.
|
||||
|
||||
For Paperclip Cloud, provision the server runtime IAM role/workload identity,
|
||||
KMS key, deployment prefix, and non-secret `PAPERCLIP_SECRETS_AWS_*` environment
|
||||
configuration before enabling AWS-backed secrets in the board UI. For
|
||||
self-hosted and local runs, use the AWS SDK default credential chain: instance
|
||||
profile, ECS task role, EKS IRSA/OIDC web identity, AWS SSO/shared config via
|
||||
`AWS_PROFILE`, or short-lived shell credentials for local development.
|
||||
|
||||
Do not store AWS root credentials or long-lived IAM user access keys in
|
||||
Paperclip secrets. Bootstrap material belongs in infrastructure IAM/workload
|
||||
identity, the process environment, an AWS profile, or the orchestrator secret
|
||||
store.
|
||||
|
||||
## Migrating Inline Secrets
|
||||
|
||||
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# low-level script for direct database maintenance
|
||||
pnpm secrets:migrate-inline-env # dry run
|
||||
pnpm secrets:migrate-inline-env --apply # apply migration
|
||||
```
|
||||
|
||||
Use the CLI command for normal operations because it goes through the Paperclip
|
||||
API, creates or rotates secret records, and updates agent env bindings with
|
||||
audit logging.
|
||||
|
||||
## Portable Declarations
|
||||
|
||||
Company exports include only environment declarations. They do not include
|
||||
secret IDs, provider references, encrypted material, or plaintext values.
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
```
|
||||
|
||||
Before importing a package into another instance, use those declarations to
|
||||
create local values or link hosted provider references in the target deployment.
|
||||
For hosted providers such as AWS Secrets Manager, the hosted provider remains
|
||||
the value custodian; Paperclip stores metadata and provider version references,
|
||||
not provider credentials or plaintext secret values.
|
||||
|
||||
## Secret References in Agent Config
|
||||
|
||||
Agent environment variables use secret references:
|
||||
|
||||
@@ -64,17 +64,6 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio
|
||||
4. Heartbeat passes the resolved code workspace to the agent run.
|
||||
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
|
||||
|
||||
## Cross-run persistence (no-remote-git contract)
|
||||
|
||||
Code state moves between runs through the local execution-workspace cwd alone — not through a git remote.
|
||||
|
||||
- Each run's prepare step bundles the local worktree to the run's remote dir over ssh, with no `git remote` configured.
|
||||
- The adapter's restore step at the end of the run writes any new remote commits back into the local worktree directly.
|
||||
- Adapters must never `git push` from runtime code, and must never assume a remote exists.
|
||||
- A failed restore is a run-level error and records `workspace_finalize=failed` on the execution workspace, which gates dependent issue wakes until the next successful finalize.
|
||||
|
||||
The invariant is enforced by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`, which asserts a remote-only commit reaches the local worktree with no remote configured at any point.
|
||||
|
||||
## Current implementation guarantees
|
||||
|
||||
With the current implementation:
|
||||
|
||||
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 41 KiB |