Compare commits
69 Commits
dev
...
dev-gitea-skills
| Author | SHA1 | Date | |
|---|---|---|---|
| e559218f98 | |||
| 9f3f71a199 | |||
| 1a1c57461f | |||
| 911a1e8b0d | |||
| aea35fe695 | |||
| 8014445b23 | |||
| 5153b01ada | |||
| 1f70fd9a22 | |||
| 524e18b060 | |||
| d9f91576a0 | |||
| 9eac727cf1 | |||
| 8da50dbcf8 | |||
| de36743583 | |||
| a49afe5ea1 | |||
| b7545823be | |||
| f0ddd24d61 | |||
| 9aea3e3d35 | |||
| 60efa38f86 | |||
| ece8a51e22 | |||
| 96f0279e08 | |||
| 897cc322c7 | |||
| e3c875c1c7 | |||
| e85ff094ec | |||
| 4811d8dd33 | |||
| 90117827eb | |||
| ad6effa65c | |||
| e43b392a79 | |||
| a1835cfa5e | |||
| 38c185fb8b | |||
| c91a062326 | |||
| f257530537 | |||
| 43c5bb81b6 | |||
| d67347be77 | |||
| 9c29394f4d | |||
| bfe6369ef5 | |||
| 24748de421 | |||
| c0c5a8263d | |||
| f343bae119 | |||
| a07e6cef7b | |||
| 988689947a | |||
| e5a0f5debd | |||
| 4b1e92a588 | |||
| 8f45d12447 | |||
| 32605b71ad | |||
| 85510f0e5b | |||
| 5071c4c776 | |||
| 242a2c2f2b | |||
| 734385102c | |||
| d734bd43d1 | |||
| 705c1b8d81 | |||
| 3e6610fb93 | |||
| 7bbdfb69df | |||
| 93cd933f79 | |||
| 573e9ec909 | |||
| 81d18f2d77 | |||
| 9b6d2e6b79 | |||
| ab8b471685 | |||
| 63821bfe4c | |||
| 4c47eb46c3 | |||
| e2d7263b07 | |||
| afb73ba553 | |||
| 7e1a27c8ec | |||
| d5ba3348a9 | |||
| eb38b226c2 | |||
| dfcebf082b | |||
| 03ad5c5bea | |||
| 901c088e14 | |||
| 333a16b035 | |||
| 1bd44c8a0d |
@@ -0,0 +1,406 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,77 @@
|
||||
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\"}"
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
@@ -29,9 +29,11 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: google-chrome --version
|
||||
|
||||
- name: Run e2e tests
|
||||
env:
|
||||
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -45,6 +45,12 @@ 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
|
||||
|
||||
@@ -62,7 +68,8 @@ jobs:
|
||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
fi
|
||||
|
||||
verify:
|
||||
typecheck_release_registry:
|
||||
name: Typecheck + Release Registry
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -88,12 +95,89 @@ jobs:
|
||||
- name: Typecheck workspaces whose build scripts skip TypeScript
|
||||
run: pnpm run typecheck:build-gaps
|
||||
|
||||
- name: Run general test suites
|
||||
run: pnpm test:run:general
|
||||
|
||||
- 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
|
||||
|
||||
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: Build
|
||||
run: pnpm build
|
||||
|
||||
@@ -195,8 +279,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- 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: Generate Paperclip config
|
||||
run: |
|
||||
@@ -216,6 +303,7 @@ 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,8 +58,10 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
- 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: Launch Docker smoke harness
|
||||
run: |
|
||||
@@ -89,6 +91,7 @@ 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
|
||||
|
||||
@@ -55,4 +55,6 @@ tests/e2e/playwright-report/
|
||||
tests/release-smoke/test-results/
|
||||
tests/release-smoke/playwright-report/
|
||||
.superset/
|
||||
.superpowers/
|
||||
.claude/worktrees/
|
||||
.herenow
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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,12 +22,14 @@ 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/
|
||||
@@ -35,7 +37,9 @@ 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
|
||||
|
||||
@@ -53,10 +57,29 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client jq \
|
||||
# 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 \
|
||||
&& 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/header.png" alt="Paperclip — runs your business" width="720" />
|
||||
<img src="doc/assets/banner.jpg" alt="Paperclip is the app people use to manage AI agents for work." width="720" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -7,7 +7,8 @@
|
||||
<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://x.com/papercliping"><strong>Twitter</strong></a> ·
|
||||
<a href="https://paperclip.ing"><strong>Website</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,15 +25,15 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## What is Paperclip?
|
||||
# Paperclip is the app people use to manage AI agents for work.
|
||||
|
||||
# Open-source orchestration for zero-human companies
|
||||
Open-source orchestration for teams of AI agents.
|
||||
|
||||
**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 your agents' 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 work and costs from one dashboard.
|
||||
|
||||
It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination.
|
||||
It looks like a task manager. Under the hood: org charts, budgets, governance, goal alignment, and agent coordination.
|
||||
|
||||
**Manage business goals, not pull requests.**
|
||||
|
||||
@@ -44,10 +45,6 @@ It looks like a task manager — but under the hood it has org charts, budgets,
|
||||
|
||||
<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>
|
||||
@@ -113,7 +110,7 @@ Every conversation traced. Every decision explained. Full tool-call tracing and
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>🛡️ Governance</h3>
|
||||
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
|
||||
Approve hires, override strategy, pause or terminate any agent — at any time.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>📊 Org Chart</h3>
|
||||
@@ -222,7 +219,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. You're the board — 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. Nothing ships without your sign-off.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -317,7 +314,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-entreprenuer 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 entrepreneur 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.
|
||||
@@ -418,7 +415,7 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
|
||||
|
||||
## License
|
||||
|
||||
MIT © 2026 Paperclip
|
||||
MIT © 2026 [Paperclip Labs, Inc](https://paperclip.ing)
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -429,9 +426,5 @@ MIT © 2026 Paperclip
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<sub>Open source under MIT. Built for people who want to get work done, not babysit agents.</sub>
|
||||
</p>
|
||||
|
||||
@@ -226,6 +226,21 @@ 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
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@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:*",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
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" },
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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(
|
||||
@@ -50,13 +52,18 @@ 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 = "";
|
||||
|
||||
const preset = buildPresetServerConfig("tailnet", {
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
});
|
||||
try {
|
||||
const preset = buildPresetServerConfig("tailnet", {
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
});
|
||||
|
||||
expect(preset.server.host).toBe("127.0.0.1");
|
||||
expect(preset.server.host).toBe("127.0.0.1");
|
||||
} finally {
|
||||
process.env.PATH = ORIGINAL_PATH;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ 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-"));
|
||||
@@ -171,8 +172,13 @@ 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 = "";
|
||||
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||
try {
|
||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||
} finally {
|
||||
process.env.PATH = ORIGINAL_PATH;
|
||||
}
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
expect(raw.server.deploymentMode).toBe("authenticated");
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
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,6 +512,45 @@ 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 () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
@@ -51,6 +52,11 @@ const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printGeminiStreamEvent,
|
||||
};
|
||||
|
||||
const grokLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "grok_local",
|
||||
formatStdoutEvent: printGrokStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
@@ -66,6 +72,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
cursorLocalCLIAdapter,
|
||||
cursorCloudCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
grokLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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") : [];
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
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;
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
ensurePostgresDatabase,
|
||||
formatEmbeddedPostgresError,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
@@ -116,6 +117,7 @@ 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,6 +45,7 @@ import {
|
||||
runDatabaseRestore,
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
formatEmbeddedPostgresError,
|
||||
prepareEmbeddedPostgresNativeRuntime,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
@@ -1059,6 +1060,7 @@ 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);
|
||||
@@ -1385,7 +1387,12 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
}
|
||||
|
||||
if (opts.force) {
|
||||
rmSync(paths.repoConfigDir, { recursive: true, force: true });
|
||||
// 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.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ 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";
|
||||
@@ -149,6 +151,8 @@ registerDashboardCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerSecretCommands(program);
|
||||
registerCloudCommands(program);
|
||||
registerSkillsCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
@@ -143,6 +143,124 @@ 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
|
||||
|
||||
@@ -143,6 +143,17 @@ 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.
|
||||
@@ -165,6 +176,10 @@ 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`:
|
||||
|
||||
|
||||
@@ -125,19 +125,50 @@ 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. Current Code Reality (As Of 2026-02-23)
|
||||
## 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)
|
||||
|
||||
- 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
|
||||
|
||||
## 9. Naming and Compatibility Policy
|
||||
## 10. 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
|
||||
|
||||
## 10. Relationship to Other Docs
|
||||
## 11. Relationship to Other Docs
|
||||
|
||||
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
|
||||
- V1 contract: `doc/SPEC-implementation.md`
|
||||
|
||||
@@ -72,6 +72,13 @@ 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:
|
||||
|
||||
@@ -413,6 +420,62 @@ 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:
|
||||
@@ -554,10 +617,12 @@ pnpm paperclipai dashboard get
|
||||
|
||||
See full command reference in `doc/CLI.md`.
|
||||
|
||||
## OpenClaw Invite Onboarding Endpoints
|
||||
## Agent 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.
|
||||
@@ -575,7 +640,7 @@ pnpm smoke:openclaw-join
|
||||
What it validates:
|
||||
|
||||
- invite creation for agent-only join
|
||||
- agent join request using `adapterType=openclaw`
|
||||
- agent join request using `adapterType=openclaw_gateway`
|
||||
- board approval + one-time API key claim semantics
|
||||
- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver
|
||||
|
||||
|
||||
@@ -117,6 +117,16 @@ 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,6 +118,7 @@ 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.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ 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 | Full visibility to board and all agents in same company |
|
||||
| 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 |
|
||||
| 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`) |
|
||||
@@ -207,6 +207,8 @@ 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
|
||||
@@ -309,7 +311,32 @@ Invariant: each event must attach to agent and company; rollups are aggregation,
|
||||
- `details` jsonb null
|
||||
- `created_at` timestamptz not null default now()
|
||||
|
||||
## 7.12 `company_secrets` + `company_secret_versions`
|
||||
## 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`
|
||||
|
||||
- Secret values are not stored inline in `agents.adapter_config.env`.
|
||||
- Agent env entries should use secret refs for sensitive values.
|
||||
@@ -323,7 +350,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.13 Required Indexes
|
||||
## 7.14 Required Indexes
|
||||
|
||||
- `agents(company_id, status)`
|
||||
- `agents(company_id, reports_to)`
|
||||
@@ -341,8 +368,12 @@ 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.14 `assets` + `issue_attachments`
|
||||
## 7.15 `assets` + `issue_attachments`
|
||||
|
||||
- `assets` stores provider-backed object metadata (not inline bytes):
|
||||
- `id` uuid pk
|
||||
@@ -376,6 +407,10 @@ 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
|
||||
@@ -396,7 +431,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, and `routines`.
|
||||
- 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`.
|
||||
- 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
|
||||
@@ -481,6 +516,59 @@ 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)
|
||||
|
||||
@@ -524,6 +612,8 @@ 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`
|
||||
@@ -562,14 +652,28 @@ Server behavior:
|
||||
- `GET /projects/:projectId`
|
||||
- `PATCH /projects/:projectId`
|
||||
|
||||
## 10.6 Approvals
|
||||
## 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
|
||||
|
||||
- `GET /companies/:companyId/approvals?status=pending`
|
||||
- `POST /companies/:companyId/approvals`
|
||||
- `POST /approvals/:approvalId/approve`
|
||||
- `POST /approvals/:approvalId/reject`
|
||||
|
||||
## 10.7 Cost and Budgets
|
||||
## 10.8 Cost and Budgets
|
||||
|
||||
- `POST /companies/:companyId/cost-events`
|
||||
- `GET /companies/:companyId/costs/summary`
|
||||
@@ -578,7 +682,7 @@ Server behavior:
|
||||
- `PATCH /companies/:companyId/budgets`
|
||||
- `PATCH /agents/:agentId/budgets`
|
||||
|
||||
## 10.8 Activity and Dashboard
|
||||
## 10.9 Activity and Dashboard
|
||||
|
||||
- `GET /companies/:companyId/activity`
|
||||
- `GET /companies/:companyId/dashboard`
|
||||
@@ -590,7 +694,7 @@ Dashboard payload must include:
|
||||
- month-to-date spend and budget utilization
|
||||
- pending approvals count
|
||||
|
||||
## 10.9 Error Semantics
|
||||
## 10.10 Error Semantics
|
||||
|
||||
- `400` validation error
|
||||
- `401` unauthenticated
|
||||
@@ -600,7 +704,7 @@ Dashboard payload must include:
|
||||
- `422` semantic rule violation
|
||||
- `500` server error
|
||||
|
||||
## 10.10 Current Implementation API Addenda
|
||||
## 10.11 Current Implementation API Addenda
|
||||
|
||||
The current app also exposes V1-supporting surfaces for:
|
||||
|
||||
@@ -671,7 +775,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 Scheduler Rules
|
||||
## 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
|
||||
|
||||
Per-agent schedule fields in `adapter_config`:
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ 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
|
||||
|
||||
|
After Width: | Height: | Size: 404 KiB |
@@ -1,7 +1,7 @@
|
||||
# Execution Semantics
|
||||
|
||||
Status: Current implementation guide
|
||||
Date: 2026-04-26
|
||||
Date: 2026-05-23
|
||||
Audience: Product and engineering
|
||||
|
||||
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
|
||||
@@ -152,7 +152,73 @@ 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. Non-Terminal Issue Liveness Contract
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -184,7 +250,7 @@ A valid recovery action must name:
|
||||
- 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: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
|
||||
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:
|
||||
|
||||
@@ -196,6 +262,14 @@ Use an issue-backed recovery action only when the recovery is genuinely independ
|
||||
|
||||
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.
|
||||
|
||||
### Agent-assigned `todo`
|
||||
|
||||
This is dispatch state: ready to start, not yet actively claimed.
|
||||
@@ -284,13 +358,13 @@ A blocker chain is covered only when its unresolved leaf is live or explicitly w
|
||||
|
||||
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.
|
||||
|
||||
## 8. Crash and Restart Recovery
|
||||
## 9. 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.
|
||||
|
||||
### 8.1 Stranded assigned `todo`
|
||||
### 9.1 Stranded assigned `todo`
|
||||
|
||||
Example:
|
||||
|
||||
@@ -306,7 +380,7 @@ Recovery rule:
|
||||
|
||||
This is a dispatch recovery, not a continuation recovery.
|
||||
|
||||
### 8.2 Stranded assigned `in_progress`
|
||||
### 9.2 Stranded assigned `in_progress`
|
||||
|
||||
Example:
|
||||
|
||||
@@ -322,20 +396,27 @@ Recovery rule:
|
||||
|
||||
This is an active-work continuity recovery.
|
||||
|
||||
## 9. Startup and Periodic Reconciliation
|
||||
### 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
|
||||
|
||||
Startup recovery and periodic recovery are different from normal wakeup delivery.
|
||||
|
||||
On startup and on the periodic recovery loop, Paperclip now does four things in sequence:
|
||||
On startup and on the periodic recovery loop, Paperclip now does five things in sequence:
|
||||
|
||||
1. reap orphaned `running` runs
|
||||
2. resume persisted `queued` runs
|
||||
3. reconcile stranded assigned work
|
||||
4. scan silent active runs and create or update explicit watchdog recovery actions
|
||||
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
|
||||
|
||||
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 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.
|
||||
|
||||
## 10. Silent Active-Run Watchdog
|
||||
## 11. 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.
|
||||
|
||||
@@ -360,7 +441,34 @@ Operators should prefer `snooze` for known time-bounded quiet periods. `continue
|
||||
|
||||
The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
|
||||
|
||||
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
### 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
|
||||
|
||||
Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
|
||||
|
||||
@@ -404,7 +512,7 @@ Examples:
|
||||
|
||||
In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying.
|
||||
|
||||
## 12. What This Does Not Mean
|
||||
## 13. What This Does Not Mean
|
||||
|
||||
These semantics do not change V1 into an auto-reassignment system.
|
||||
|
||||
@@ -421,7 +529,7 @@ The recovery model is intentionally conservative:
|
||||
- open an explicit recovery action when the system can identify a bounded recovery owner/action
|
||||
- escalate visibly when the system cannot safely keep going
|
||||
|
||||
## 13. Practical Interpretation
|
||||
## 14. Practical Interpretation
|
||||
|
||||
For a board operator, the intended meaning is:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 2026-03-14 Adapter Skill Sync Rollout
|
||||
|
||||
Status: Proposed
|
||||
Status: Implemented for local adapters; gateway remains unsupported
|
||||
Date: 2026-03-14
|
||||
Audience: Product and engineering
|
||||
Related:
|
||||
@@ -25,8 +25,10 @@ Paperclip currently has these adapters:
|
||||
|
||||
- `claude_local`
|
||||
- `codex_local`
|
||||
- `cursor_local`
|
||||
- `cursor`
|
||||
- `gemini_local`
|
||||
- `grok_local`
|
||||
- `acpx_local`
|
||||
- `opencode_local`
|
||||
- `pi_local`
|
||||
- `openclaw_gateway`
|
||||
@@ -39,12 +41,14 @@ The current skill API supports:
|
||||
|
||||
Current implementation state:
|
||||
|
||||
- `codex_local`: implemented, `persistent`
|
||||
- `codex_local`: implemented, `ephemeral`
|
||||
- `claude_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
|
||||
- `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`
|
||||
- `openclaw_gateway`: not yet implemented; blocked on gateway protocol support, so `unsupported` for now
|
||||
|
||||
## 3. Product Principles
|
||||
@@ -64,8 +68,7 @@ These adapters have a stable local skills directory that Paperclip can read and
|
||||
|
||||
Candidates:
|
||||
|
||||
- `codex_local`
|
||||
- `cursor_local`
|
||||
- `cursor`
|
||||
- `gemini_local`
|
||||
- `pi_local`
|
||||
- `opencode_local` with caveats
|
||||
@@ -84,7 +87,10 @@ 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:
|
||||
|
||||
@@ -99,6 +105,7 @@ These adapters cannot support skill sync without new external capabilities.
|
||||
|
||||
Current adapter:
|
||||
|
||||
- `acpx_local` when configured for custom commands
|
||||
- `openclaw_gateway`
|
||||
|
||||
Expected UX:
|
||||
@@ -114,7 +121,7 @@ Expected UX:
|
||||
|
||||
Target mode:
|
||||
|
||||
- `persistent`
|
||||
- `ephemeral`
|
||||
|
||||
Current state:
|
||||
|
||||
@@ -122,15 +129,15 @@ Current state:
|
||||
|
||||
Requirements to finish:
|
||||
|
||||
- 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
|
||||
- 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`
|
||||
|
||||
Success criteria:
|
||||
|
||||
- list installed managed and external skills
|
||||
- sync desired skills into `CODEX_HOME/skills`
|
||||
- preserve external user-managed skills
|
||||
- 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`
|
||||
|
||||
### 5.2 Claude Local
|
||||
|
||||
@@ -162,18 +169,11 @@ Target mode:
|
||||
|
||||
Technical basis:
|
||||
|
||||
- runtime already injects Paperclip skills into `~/.cursor/skills`
|
||||
- Paperclip reconciles desired skills into `~/.cursor/skills`
|
||||
|
||||
Implementation work:
|
||||
Current state:
|
||||
|
||||
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
|
||||
- implemented
|
||||
|
||||
Testing:
|
||||
|
||||
@@ -194,14 +194,11 @@ Target mode:
|
||||
|
||||
Technical basis:
|
||||
|
||||
- runtime already injects Paperclip skills into `~/.gemini/skills`
|
||||
- Paperclip reconciles desired skills into `~/.gemini/skills`
|
||||
|
||||
Implementation work:
|
||||
Current state:
|
||||
|
||||
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.
|
||||
- implemented
|
||||
|
||||
Potential caveat:
|
||||
|
||||
@@ -219,14 +216,11 @@ Target mode:
|
||||
|
||||
Technical basis:
|
||||
|
||||
- runtime already injects Paperclip skills into `~/.pi/agent/skills`
|
||||
- Paperclip reconciles desired skills into `~/.pi/agent/skills`
|
||||
|
||||
Implementation work:
|
||||
Current state:
|
||||
|
||||
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.
|
||||
- implemented
|
||||
|
||||
Success criteria:
|
||||
|
||||
@@ -250,9 +244,7 @@ This is product-risky because:
|
||||
|
||||
Plan:
|
||||
|
||||
Phase 1:
|
||||
|
||||
- implement `listSkills` and `syncSkills`
|
||||
- implemented `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
|
||||
@@ -290,6 +282,30 @@ 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
|
||||
@@ -333,14 +349,13 @@ Additional UI requirement for shared-home adapters:
|
||||
|
||||
Ship:
|
||||
|
||||
- `cursor_local`
|
||||
- `cursor`
|
||||
- `gemini_local`
|
||||
- `pi_local`
|
||||
|
||||
Rationale:
|
||||
Status:
|
||||
|
||||
- these are the closest to Codex in architecture
|
||||
- they already inject into stable local skill homes
|
||||
- implemented
|
||||
|
||||
### Phase 2: OpenCode shared-home support
|
||||
|
||||
@@ -348,10 +363,9 @@ Ship:
|
||||
|
||||
- `opencode_local`
|
||||
|
||||
Rationale:
|
||||
Status:
|
||||
|
||||
- technically feasible now
|
||||
- needs slightly more careful product language because of the shared Claude skills home
|
||||
- implemented with shared Claude skills-home warning
|
||||
|
||||
### Phase 3: Gateway support decision
|
||||
|
||||
@@ -390,10 +404,10 @@ Adapter-wide skill support is ready when all are true:
|
||||
|
||||
The recommended immediate order is:
|
||||
|
||||
1. `cursor_local`
|
||||
1. `cursor`
|
||||
2. `gemini_local`
|
||||
3. `pi_local`
|
||||
4. `opencode_local`
|
||||
5. defer `openclaw_gateway`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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
|
||||
@@ -0,0 +1,250 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,486 @@
|
||||
# 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.
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
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, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
|
||||
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
|
||||
|
||||
@@ -126,7 +131,8 @@ When you are done iterating locally, publish the package and reinstall the npm-p
|
||||
|
||||
- **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, 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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
@@ -74,6 +76,7 @@ Worker:
|
||||
- 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
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
@@ -134,11 +137,16 @@ paths; they always remain under `/api/plugins/:pluginId/api/*`.
|
||||
|
||||
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, plugin-owned projects, and recurring automation.
|
||||
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
|
||||
@@ -155,10 +163,14 @@ Use these surfaces:
|
||||
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()`, and `ctx.routines.managed.reconcile()` for
|
||||
`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
|
||||
@@ -185,6 +197,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"routines.managed",
|
||||
"skills.managed",
|
||||
"instance.settings.register",
|
||||
],
|
||||
entrypoints: {
|
||||
@@ -231,6 +244,13 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
],
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
skillKey: "weekly-brief-skills",
|
||||
displayName: "Weekly Briefer",
|
||||
description: "Reusable skill for the managed research workflow.",
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
@@ -261,8 +281,9 @@ export default definePlugin({
|
||||
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 };
|
||||
return { project, agent, routine, skill };
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -270,14 +291,18 @@ export default definePlugin({
|
||||
|
||||
Authoring rules:
|
||||
|
||||
- Keep keys stable once published. Renaming `agentKey`, `projectKey`, or
|
||||
`routineKey` creates a new managed resource from the host's point of view.
|
||||
- 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`.
|
||||
@@ -300,6 +325,7 @@ Mount surfaces currently wired in the host include:
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `routeSidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
@@ -317,6 +343,10 @@ 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.
|
||||
|
||||
@@ -319,7 +319,10 @@ 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: {
|
||||
@@ -335,15 +338,42 @@ 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" | "dashboardWidget" | "sidebar" | "settingsPage";
|
||||
type: "page"
|
||||
| "detailTab"
|
||||
| "taskDetailView"
|
||||
| "dashboardWidget"
|
||||
| "sidebar"
|
||||
| "routeSidebar"
|
||||
| "sidebarPanel"
|
||||
| "projectSidebarItem"
|
||||
| "globalToolbarButton"
|
||||
| "toolbarButton"
|
||||
| "contextMenuItem"
|
||||
| "commentAnnotation"
|
||||
| "commentContextMenuItem"
|
||||
| "settingsPage"
|
||||
| "companySettingsPage";
|
||||
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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -354,10 +384,17 @@ 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
|
||||
|
||||
@@ -631,6 +668,22 @@ 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`.
|
||||
@@ -746,20 +799,38 @@ 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
|
||||
|
||||
@@ -772,8 +843,10 @@ 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
|
||||
|
||||
@@ -786,6 +859,7 @@ 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
|
||||
@@ -894,6 +968,7 @@ 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
|
||||
|
||||
@@ -1134,6 +1209,8 @@ 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.
|
||||
@@ -1383,6 +1460,14 @@ 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.
|
||||
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After 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/pages/CompanySettings.tsx`
|
||||
- `ui/src/components/NewAgentDialog.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 screen]
|
||||
Board[Board user on invite or add-agent screen]
|
||||
HumanInvite[Create human company invite]
|
||||
OpenClawInvite[Generate OpenClaw invite prompt]
|
||||
AgentInvite[Generate agent onboarding 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[Join request<br/>pending_approval]
|
||||
HumanPending[Legacy human 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 --> OpenClawInvite --> Active
|
||||
Board --> AgentInvite --> Active
|
||||
Active --> Revoked: revoke
|
||||
Active --> Expired: expiresAt passes
|
||||
|
||||
@@ -52,12 +52,10 @@ flowchart TD
|
||||
BootstrapDone --> Accepted
|
||||
|
||||
Active --> HumanReuse: human accept
|
||||
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
|
||||
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
|
||||
HumanPending --> HumanRejected: board rejects
|
||||
HumanPending --> Accepted
|
||||
HumanApproved --> Accepted
|
||||
|
||||
Active --> AgentPending: agent accept
|
||||
@@ -102,10 +100,10 @@ stateDiagram-v2
|
||||
LatestInviteVisible --> Ready: navigate away or refresh
|
||||
}
|
||||
|
||||
CompanySelection --> OpenClawPromptReady: Company settings prompt generator
|
||||
OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt
|
||||
OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated
|
||||
OpenClawPromptPending --> OpenClawPromptReady: generation failed
|
||||
CompanySelection --> AgentPromptReady: Add-agent modal prompt generator
|
||||
AgentPromptReady --> AgentPromptPending: Generate agent onboarding prompt
|
||||
AgentPromptPending --> AgentSnippetVisible: prompt generated
|
||||
AgentPromptPending --> AgentPromptReady: generation failed
|
||||
```
|
||||
|
||||
## Invite Landing Screen States
|
||||
@@ -150,7 +148,8 @@ stateDiagram-v2
|
||||
|
||||
state AcceptedInviteSummary {
|
||||
[*] --> SummaryBranch
|
||||
SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval
|
||||
SummaryBranch --> AcceptPending: human joinRequestStatus=pending_approval/approved<br/>and membership missing
|
||||
SummaryBranch --> PendingApprovalReload: agent 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
|
||||
@@ -177,6 +176,7 @@ 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,15 +197,19 @@ sequenceDiagram
|
||||
API->>Join: Look for reusable human join request
|
||||
alt Reusable pending or approved request exists
|
||||
API->>Invites: Mark invite accepted
|
||||
API-->>Landing: Existing join request status
|
||||
API->>Membership: Ensure active membership and role grants
|
||||
API->>Join: Mark join request approved if needed
|
||||
API-->>Landing: approved join request
|
||||
else No reusable request exists
|
||||
API->>Invites: Mark invite accepted
|
||||
API->>Join: Insert pending_approval join request
|
||||
API-->>Landing: New pending_approval join request
|
||||
API->>Membership: Ensure active membership and role grants
|
||||
API->>Join: Mark join request approved
|
||||
API-->>Landing: approved join request
|
||||
end
|
||||
```
|
||||
|
||||
### Human Approval And Reload Path
|
||||
### Legacy Human Reload And Repair Path
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -214,8 +218,6 @@ 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
|
||||
@@ -223,20 +225,15 @@ sequenceDiagram
|
||||
API->>Join: Load join request by inviteId
|
||||
API-->>Landing: joinRequestStatus + joinRequestType
|
||||
|
||||
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
|
||||
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
|
||||
API-->>Landing: approved status
|
||||
Landing-->>Invitee: Opening company and redirect
|
||||
else joinRequestStatus = rejected
|
||||
Landing-->>Invitee: Show rejected error panel
|
||||
else joinRequestStatus = approved but membership missing
|
||||
else agent invite or unavailable consumed state
|
||||
Landing-->>Invitee: Fall through to consumed/unavailable state
|
||||
end
|
||||
```
|
||||
@@ -247,21 +244,21 @@ sequenceDiagram
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Board as Board user
|
||||
participant Settings as Company Settings UI
|
||||
participant AddAgent as Add agent modal
|
||||
participant API as Access routes
|
||||
participant Invites as invites table
|
||||
actor Gateway as OpenClaw gateway agent
|
||||
actor Gateway as External 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->>Settings: Generate OpenClaw invite prompt
|
||||
Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt
|
||||
Board->>AddAgent: Generate agent onboarding prompt
|
||||
AddAgent->>API: POST /api/companies/:companyId/invites (allowedJoinTypes=agent)
|
||||
API->>Invites: Insert active agent invite
|
||||
API-->>Settings: Prompt text + invite token
|
||||
API-->>AddAgent: Prompt text + invite token
|
||||
|
||||
Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway)
|
||||
Gateway->>API: POST /api/invites/:token/accept (agent, adapter-specific payload)
|
||||
API->>Invites: Mark invite accepted
|
||||
API->>Join: Insert pending_approval join request + claimSecretHash
|
||||
API-->>Gateway: requestId + claimSecret + claimApiKeyPath
|
||||
@@ -286,14 +283,15 @@ 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 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.
|
||||
- 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.
|
||||
- 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:
|
||||
- `pending_approval` re-renders the waiting-for-approval panel
|
||||
- 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
|
||||
- `rejected` renders the "This join request was not approved." error panel
|
||||
- `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
|
||||
- `approved` becomes a success path for human invites after membership is visible to the current session
|
||||
- `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`.
|
||||
- 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`.
|
||||
- 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`.
|
||||
- `bootstrap_ceo` invites are one-time and do not create join requests.
|
||||
|
||||
@@ -249,6 +249,23 @@ 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)
|
||||
|
||||
@@ -63,6 +63,29 @@ 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
|
||||
|
||||
@@ -64,6 +64,17 @@ 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:
|
||||
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 733 KiB |
|
After Width: | Height: | Size: 164 KiB |
@@ -19,8 +19,8 @@
|
||||
"test": "pnpm run test:run",
|
||||
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
||||
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
|
||||
"test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general",
|
||||
"test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized",
|
||||
"test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode general",
|
||||
"test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode serialized",
|
||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||
"issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts",
|
||||
@@ -35,12 +35,14 @@
|
||||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"check:no-git-push": "node scripts/check-no-git-push.mjs",
|
||||
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
|
||||
"docs:dev": "cd docs && npx mintlify dev",
|
||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
|
||||
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
Shared utilities for Paperclip adapters: process spawning, environment
|
||||
injection, sandbox/SSH transport, workspace sync, and the round-trip helpers
|
||||
that move code between the local execution-workspace cwd and wherever the
|
||||
agent actually runs.
|
||||
|
||||
For the adapter-author guide see
|
||||
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md)
|
||||
and the in-repo notes at [`packages/adapters/AUTHORING.md`](../adapters/AUTHORING.md).
|
||||
|
||||
## 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.
|
||||
|
||||
Adapters that run the agent on a different host should use the SSH round-trip
|
||||
helpers in [`src/ssh.ts`](./src/ssh.ts):
|
||||
|
||||
- `prepareWorkspaceForSshExecution({ spec, localDir, remoteDir })` — bundles
|
||||
the local cwd (tracked files, dirty edits, untracked additions, and the git
|
||||
history needed to reconstruct it) to `remoteDir` before the run starts. Runs
|
||||
with no `git remote` configured.
|
||||
- `restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... })` —
|
||||
syncs the remote cwd back into `localDir` after the run, including any new
|
||||
commits the agent created. Also runs with no `git remote` configured.
|
||||
|
||||
`prepareRemoteManagedRuntime` in
|
||||
[`src/remote-managed-runtime.ts`](./src/remote-managed-runtime.ts) wraps both
|
||||
calls for adapters that want a per-run remote workspace and an automatic
|
||||
`restoreWorkspace()` finally hook.
|
||||
|
||||
The invariant is pinned by the `no-remote-git contract` case in
|
||||
[`src/ssh-fixture.test.ts`](./src/ssh-fixture.test.ts), which asserts that a
|
||||
remote-only commit propagates to the local worktree through the
|
||||
prepare → restore round-trip with no git remote configured at any point. Do
|
||||
not regress that test.
|
||||
@@ -1,20 +1,57 @@
|
||||
export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***";
|
||||
|
||||
const COMMAND_CLI_SECRET_OPTION_RE =
|
||||
/(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi;
|
||||
const COMMAND_ENV_SECRET_ASSIGNMENT_RE =
|
||||
/(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi;
|
||||
const SECRET_NAME_PATTERN =
|
||||
String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`;
|
||||
|
||||
const COMMAND_CLI_SECRET_OPTION_RE = new RegExp(
|
||||
String.raw`(\B-{1,2}${SECRET_NAME_PATTERN}(?:\s+|=)(["']?))[^\s"'` + "`" + String.raw`]+(\2)`,
|
||||
"gi",
|
||||
);
|
||||
const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp(
|
||||
String.raw`(\b${SECRET_NAME_PATTERN}\s*=\s*)(?:(["'])([^"'` + "`" + String.raw`\r\n]*)\2|([^\s"'` + "`" + String.raw`]+))`,
|
||||
"gi",
|
||||
);
|
||||
const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
|
||||
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
|
||||
const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
|
||||
const COMMAND_JWT_RE =
|
||||
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
|
||||
const COMMAND_SECRET_HINTS = [
|
||||
"api",
|
||||
"key",
|
||||
"token",
|
||||
"auth",
|
||||
"bearer",
|
||||
"secret",
|
||||
"pass",
|
||||
"credential",
|
||||
"jwt",
|
||||
"private",
|
||||
"cookie",
|
||||
"connectionstring",
|
||||
"sk-",
|
||||
"ghp_",
|
||||
"gho_",
|
||||
"ghu_",
|
||||
"ghs_",
|
||||
"ghr_",
|
||||
] as const;
|
||||
|
||||
function maybeContainsSecretText(command: string) {
|
||||
const lower = command.toLowerCase();
|
||||
return COMMAND_SECRET_HINTS.some((hint) => lower.includes(hint)) || command.includes(".");
|
||||
}
|
||||
|
||||
export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
|
||||
if (!maybeContainsSecretText(command)) return command;
|
||||
return command
|
||||
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
|
||||
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
|
||||
.replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`)
|
||||
.replace(
|
||||
COMMAND_ENV_SECRET_ASSIGNMENT_RE,
|
||||
(_match, prefix: string, quote: string | undefined) =>
|
||||
quote ? `${prefix}${quote}${redactedValue}${quote}` : `${prefix}${redactedValue}`,
|
||||
)
|
||||
.replace(COMMAND_OPENAI_KEY_RE, redactedValue)
|
||||
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
|
||||
.replace(COMMAND_JWT_RE, redactedValue);
|
||||
|
||||
@@ -138,6 +138,13 @@ async function createTarballFromDirectory(input: {
|
||||
const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]);
|
||||
await execTar([
|
||||
"-c",
|
||||
// Prevent macOS bsdtar from embedding LIBARCHIVE.xattr.* PAX extended
|
||||
// headers for extended attributes (e.g. com.apple.provenance). GNU tar on
|
||||
// Linux does not recognise these proprietary headers and fails extraction
|
||||
// with "This does not look like a tar archive". COPYFILE_DISABLE=1 (set in
|
||||
// execTar) already suppresses AppleDouble ._* sidecar files; --no-xattrs
|
||||
// additionally suppresses the inline PAX xattr entries.
|
||||
"--no-xattrs",
|
||||
...(input.followSymlinks ? ["-h"] : []),
|
||||
"-f",
|
||||
input.archivePath,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPaperclipWorkspaceEnv,
|
||||
appendWithByteCap,
|
||||
buildPersistentSkillSnapshot,
|
||||
buildRuntimeMountedSkillSnapshot,
|
||||
buildInvocationEnvForLogs,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
materializePaperclipSkillCopy,
|
||||
@@ -53,13 +55,14 @@ describe("buildInvocationEnvForLogs", () => {
|
||||
const loggedEnv = buildInvocationEnvForLogs(
|
||||
{ SAFE_VALUE: "visible" },
|
||||
{
|
||||
resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret",
|
||||
resolvedCommand:
|
||||
"env OPENAI_API_KEY=sk-live-example PAPERCLIP_API_KEY='paperclip-quoted-secret' custom-acp --paperclip-api-key=paperclip-flag-secret --token ghp_example_secret",
|
||||
},
|
||||
);
|
||||
|
||||
expect(loggedEnv.SAFE_VALUE).toBe("visible");
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(
|
||||
"env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***",
|
||||
"env OPENAI_API_KEY=***REDACTED*** PAPERCLIP_API_KEY='***REDACTED***' custom-acp --paperclip-api-key=***REDACTED*** --token ***REDACTED***",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -204,6 +207,186 @@ describe("materializePaperclipSkillCopy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapter skill snapshots", () => {
|
||||
const requiredEntry = {
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: "/runtime/paperclip",
|
||||
required: true,
|
||||
requiredReason: "Required for Paperclip heartbeats.",
|
||||
};
|
||||
const optionalEntry = {
|
||||
key: "company/ascii-heart",
|
||||
runtimeName: "ascii-heart",
|
||||
source: "/runtime/ascii-heart",
|
||||
};
|
||||
|
||||
it("reports runtime-mounted adapters as configured or missing without install state", () => {
|
||||
const snapshot = buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "codex_local",
|
||||
availableEntries: [requiredEntry],
|
||||
desiredSkills: [requiredEntry.key, "missing-skill"],
|
||||
configuredDetail: "Mounted on next run.",
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: [requiredEntry.key, "missing-skill"],
|
||||
});
|
||||
expect(snapshot.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "missing-skill",
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
desired: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: requiredEntry.key,
|
||||
state: "configured",
|
||||
origin: "paperclip_required",
|
||||
required: true,
|
||||
detail: "Mounted on next run.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports source-missing company runtime skills without orphan warnings", () => {
|
||||
const snapshot = buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "codex_local",
|
||||
availableEntries: [{
|
||||
key: "company/example/reflection-coach",
|
||||
runtimeName: "reflection-coach--abc123",
|
||||
source: "/paperclip/skills/example/__runtime__/reflection-coach--abc123",
|
||||
sourceStatus: "missing",
|
||||
missingDetail: "Company skill exists, but its local source is missing.",
|
||||
}],
|
||||
desiredSkills: ["company/example/reflection-coach"],
|
||||
configuredDetail: "Mounted on next run.",
|
||||
});
|
||||
|
||||
expect(snapshot.warnings).toEqual([]);
|
||||
expect(snapshot.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "company/example/reflection-coach",
|
||||
state: "missing",
|
||||
origin: "company_managed",
|
||||
sourcePath: null,
|
||||
detail: "Company skill exists, but its local source is missing.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unsupported runtime-mounted adapters in tracked-only state", () => {
|
||||
const snapshot = buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "acpx_local",
|
||||
availableEntries: [requiredEntry],
|
||||
desiredSkills: [requiredEntry.key],
|
||||
configuredDetail: "Mounted on next run.",
|
||||
mode: "unsupported",
|
||||
unsupportedDetail: "Tracked only.",
|
||||
});
|
||||
|
||||
expect(snapshot.supported).toBe(false);
|
||||
expect(snapshot.mode).toBe("unsupported");
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: requiredEntry.key,
|
||||
desired: true,
|
||||
state: "available",
|
||||
detail: "Tracked only.",
|
||||
}));
|
||||
});
|
||||
|
||||
it("can surface read-only external skills for runtime-mounted adapters", () => {
|
||||
const snapshot = buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "claude_local",
|
||||
availableEntries: [requiredEntry],
|
||||
desiredSkills: [requiredEntry.key],
|
||||
configuredDetail: "Mounted on next run.",
|
||||
externalInstalled: new Map([
|
||||
["crack-python", { targetPath: "/home/me/.claude/skills/crack-python", kind: "directory" }],
|
||||
]),
|
||||
externalLocationLabel: "~/.claude/skills",
|
||||
externalDetail: "Installed outside Paperclip management in the Claude skills home.",
|
||||
});
|
||||
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: "crack-python",
|
||||
runtimeName: "crack-python",
|
||||
state: "external",
|
||||
managed: false,
|
||||
origin: "user_installed",
|
||||
locationLabel: "~/.claude/skills",
|
||||
readOnly: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it("reports persistent adapter installed, stale, external, and missing states", () => {
|
||||
const snapshot = buildPersistentSkillSnapshot({
|
||||
adapterType: "cursor",
|
||||
availableEntries: [requiredEntry, optionalEntry],
|
||||
desiredSkills: [requiredEntry.key, "missing-skill"],
|
||||
installed: new Map([
|
||||
["paperclip", { targetPath: "/runtime/paperclip", kind: "symlink" }],
|
||||
["ascii-heart", { targetPath: "/other/ascii-heart", kind: "directory" }],
|
||||
["old-managed", { targetPath: "/runtime/old-managed", kind: "symlink" }],
|
||||
]),
|
||||
skillsHome: "/home/me/.cursor/skills",
|
||||
locationLabel: "~/.cursor/skills",
|
||||
installedDetail: "Installed in the Cursor skills home.",
|
||||
missingDetail: "Configured but not linked.",
|
||||
externalConflictDetail: "Name occupied externally.",
|
||||
externalDetail: "Installed outside Paperclip management.",
|
||||
});
|
||||
|
||||
expect(snapshot.mode).toBe("persistent");
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: requiredEntry.key,
|
||||
state: "installed",
|
||||
managed: true,
|
||||
origin: "paperclip_required",
|
||||
}));
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: optionalEntry.key,
|
||||
state: "external",
|
||||
managed: false,
|
||||
detail: "Installed outside Paperclip management.",
|
||||
}));
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: "missing-skill",
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
}));
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: "old-managed",
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
}));
|
||||
});
|
||||
|
||||
it("reports stale managed persistent skills when Paperclip owns an undesired available skill", () => {
|
||||
const snapshot = buildPersistentSkillSnapshot({
|
||||
adapterType: "cursor",
|
||||
availableEntries: [optionalEntry],
|
||||
desiredSkills: [],
|
||||
installed: new Map([
|
||||
["ascii-heart", { targetPath: "/runtime/ascii-heart", kind: "symlink" }],
|
||||
]),
|
||||
skillsHome: "/home/me/.cursor/skills",
|
||||
missingDetail: "Configured but not linked.",
|
||||
externalConflictDetail: "Name occupied externally.",
|
||||
externalDetail: "Installed outside Paperclip management.",
|
||||
});
|
||||
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: optionalEntry.key,
|
||||
desired: false,
|
||||
state: "stale",
|
||||
managed: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("runChildProcess", () => {
|
||||
it("does not arm a timeout when timeoutSec is 0", async () => {
|
||||
const result = await runChildProcess(
|
||||
@@ -462,6 +645,50 @@ describe("renderPaperclipWakePrompt", () => {
|
||||
expect(prompt).toContain("named unblock owner/action");
|
||||
});
|
||||
|
||||
it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => {
|
||||
const title = "验证中文任务";
|
||||
const commentBody = [
|
||||
"请用中文回复。",
|
||||
"日本語: 次の手順を書いてください。",
|
||||
"हिन्दी: कृपया स्थिति बताएं।",
|
||||
].join("\n");
|
||||
const payload = {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-9452",
|
||||
title,
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
},
|
||||
commentIds: ["comment-1"],
|
||||
latestCommentId: "comment-1",
|
||||
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
body: commentBody,
|
||||
author: { type: "user", id: "board-user-1" },
|
||||
createdAt: "2026-05-15T16:30:00.000Z",
|
||||
},
|
||||
],
|
||||
fallbackFetchNeeded: false,
|
||||
};
|
||||
|
||||
const serialized = stringifyPaperclipWakePayload(payload);
|
||||
expect(serialized).toContain(title);
|
||||
expect(serialized).toContain("日本語");
|
||||
expect(serialized).toContain("हिन्दी");
|
||||
expect(JSON.parse(serialized ?? "{}")).toMatchObject({
|
||||
issue: { title },
|
||||
comments: [{ body: commentBody }],
|
||||
});
|
||||
|
||||
const prompt = renderPaperclipWakePrompt(payload);
|
||||
expect(prompt).toContain(`- issue: PAP-9452 ${title}`);
|
||||
expect(prompt).toContain(commentBody);
|
||||
});
|
||||
|
||||
it("renders planning-mode directives for assignment and comment wakes", () => {
|
||||
const assignmentPrompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_assigned",
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface PaperclipSkillEntry {
|
||||
key: string;
|
||||
runtimeName: string;
|
||||
source: string;
|
||||
sourceStatus?: "available" | "missing";
|
||||
missingDetail?: string | null;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
}
|
||||
@@ -161,6 +163,22 @@ interface PersistentSkillSnapshotOptions {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface RuntimeMountedSkillSnapshotOptions {
|
||||
adapterType: string;
|
||||
availableEntries: PaperclipSkillEntry[];
|
||||
desiredSkills: string[];
|
||||
configuredDetail: string | ((entry: PaperclipSkillEntry) => string | null);
|
||||
missingDetail?: string;
|
||||
mode?: "ephemeral" | "unsupported";
|
||||
supported?: boolean;
|
||||
unsupportedDetail?: string | ((entry: PaperclipSkillEntry) => string | null);
|
||||
warnings?: string[];
|
||||
externalInstalled?: Map<string, InstalledSkillTarget>;
|
||||
externalLocationLabel?: string | null;
|
||||
externalDetail?: string;
|
||||
skillsHome?: string;
|
||||
}
|
||||
|
||||
function normalizePathSlashes(value: string): string {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
@@ -193,6 +211,26 @@ function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
function isPaperclipSkillSourceMissing(entry: PaperclipSkillEntry) {
|
||||
return entry.sourceStatus === "missing";
|
||||
}
|
||||
|
||||
function resolvePaperclipSkillMissingDetail(
|
||||
entry: PaperclipSkillEntry,
|
||||
fallback: string,
|
||||
) {
|
||||
return entry.missingDetail?.trim() || fallback;
|
||||
}
|
||||
|
||||
function resolveSkillDetail(
|
||||
detail: string | ((entry: PaperclipSkillEntry) => string | null) | null | undefined,
|
||||
entry: PaperclipSkillEntry,
|
||||
): string | null {
|
||||
if (typeof detail === "function") return detail(entry);
|
||||
if (typeof detail === "string") return detail;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveInstalledEntryTarget(
|
||||
skillsHome: string,
|
||||
entryName: string,
|
||||
@@ -1381,6 +1419,120 @@ export async function readInstalledSkillTargets(skillsHome: string): Promise<Map
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildRuntimeMountedSkillSnapshot(
|
||||
options: RuntimeMountedSkillSnapshotOptions,
|
||||
): AdapterSkillSnapshot {
|
||||
const {
|
||||
adapterType,
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
configuredDetail,
|
||||
missingDetail = "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
mode = "ephemeral",
|
||||
externalInstalled,
|
||||
externalLocationLabel,
|
||||
externalDetail = "Installed outside Paperclip management.",
|
||||
skillsHome,
|
||||
} = options;
|
||||
const supported = options.supported ?? mode !== "unsupported";
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings = [...(options.warnings ?? [])];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const desired = desiredSet.has(available.key);
|
||||
if (isPaperclipSkillSourceMissing(available)) {
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const configured = supported && mode === "ephemeral" && desired;
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: configured ? "configured" : "available",
|
||||
sourcePath: available.source,
|
||||
targetPath: null,
|
||||
detail: desired
|
||||
? configured
|
||||
? resolveSkillDetail(configuredDetail, available)
|
||||
: resolveSkillDetail(
|
||||
options.unsupportedDetail
|
||||
?? "Desired state is stored in Paperclip only; this adapter cannot apply skills at runtime.",
|
||||
available,
|
||||
)
|
||||
: null,
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: missingDetail,
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (externalInstalled) {
|
||||
for (const [name, installedEntry] of externalInstalled.entries()) {
|
||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||
entries.push({
|
||||
key: name,
|
||||
runtimeName: name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: skillLocationLabel(externalLocationLabel),
|
||||
readOnly: true,
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? (skillsHome ? path.join(skillsHome, name) : null),
|
||||
detail: externalDetail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
adapterType,
|
||||
supported,
|
||||
mode,
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPersistentSkillSnapshot(
|
||||
options: PersistentSkillSnapshotOptions,
|
||||
): AdapterSkillSnapshot {
|
||||
@@ -1404,6 +1556,26 @@ export function buildPersistentSkillSnapshot(
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||
const desired = desiredSet.has(available.key);
|
||||
if (isPaperclipSkillSourceMissing(available)) {
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: path.join(skillsHome, available.runtimeName),
|
||||
detail: resolvePaperclipSkillMissingDetail(
|
||||
available,
|
||||
missingDetail,
|
||||
),
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
@@ -1496,6 +1668,11 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
|
||||
key,
|
||||
runtimeName,
|
||||
source,
|
||||
sourceStatus: entry.sourceStatus === "missing" ? "missing" : "available",
|
||||
missingDetail:
|
||||
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
|
||||
? entry.missingDetail.trim()
|
||||
: null,
|
||||
required: asBoolean(entry.required, false),
|
||||
requiredReason:
|
||||
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
|
||||
|
||||
@@ -451,6 +451,68 @@ describe("ssh env-lab fixture", () => {
|
||||
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
|
||||
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("propagates remote commits to the local worktree with no git remote configured (no-remote-git contract)", async () => {
|
||||
// Locks in the architectural contract documented in
|
||||
// packages/adapter-utils/README.md and packages/adapters/AUTHORING.md:
|
||||
// the local execution-workspace cwd is the only persistence boundary
|
||||
// across runs. No adapter may depend on a git remote for cross-run state.
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const statePath = path.join(rootDir, "state.json");
|
||||
const localRepo = path.join(rootDir, "local-workspace");
|
||||
|
||||
await mkdir(localRepo, { recursive: true });
|
||||
await git(localRepo, ["init"]);
|
||||
await git(localRepo, ["checkout", "-b", "main"]);
|
||||
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
|
||||
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
|
||||
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
|
||||
await git(localRepo, ["add", "tracked.txt"]);
|
||||
await git(localRepo, ["commit", "-m", "initial"]);
|
||||
|
||||
// Assert there is no git remote configured before we begin, and verify
|
||||
// that no point in the round-trip introduces one. `git remote` returns an
|
||||
// empty string when no remotes exist (and exit code 0).
|
||||
expect(await git(localRepo, ["remote"])).toBe("");
|
||||
|
||||
const started = await startSshEnvLabFixtureOrSkip(
|
||||
statePath,
|
||||
"no-remote-git contract test",
|
||||
);
|
||||
if (!started) return;
|
||||
const config = await buildSshEnvLabFixtureConfig(started);
|
||||
const spec = {
|
||||
...config,
|
||||
remoteCwd: started.workspaceDir,
|
||||
} as const;
|
||||
|
||||
const prepared = await prepareRemoteManagedRuntime({
|
||||
spec,
|
||||
runId: "run-no-remote",
|
||||
adapterKey: "test-adapter",
|
||||
workspaceLocalDir: localRepo,
|
||||
});
|
||||
|
||||
// Remote commit lands a deliverable that must show up locally via
|
||||
// sync-back alone — no `git push`, no fetch from any origin.
|
||||
await runSshCommand(
|
||||
config,
|
||||
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "deliverable\\n" > tracked.txt && git add tracked.txt && git commit -m "remote-only commit" >/dev/null`,
|
||||
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
||||
);
|
||||
|
||||
await prepared.restoreWorkspace();
|
||||
|
||||
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe(
|
||||
"remote-only commit",
|
||||
);
|
||||
expect(await readFile(path.join(localRepo, "tracked.txt"), "utf8")).toBe(
|
||||
"deliverable\n",
|
||||
);
|
||||
// Final assertion: still no git remote — restore did not silently add one.
|
||||
expect(await git(localRepo, ["remote"])).toBe("");
|
||||
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("merges concurrent remote commits through the managed runtime restore path", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Adapter Authoring Notes
|
||||
|
||||
In-repo notes for adapter authors. The user-facing guide lives at
|
||||
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md);
|
||||
this file holds invariants that are easy to violate from inside the adapter
|
||||
package itself.
|
||||
|
||||
## No-remote-git contract (cross-run persistence)
|
||||
|
||||
The local execution-workspace cwd is the only persistence boundary across
|
||||
runs. No adapter may depend on a git remote for cross-run state.
|
||||
|
||||
Why: Paperclip resolves a local execution workspace (a worktree) for each
|
||||
heartbeat. Code state is carried forward by syncing that local cwd to wherever
|
||||
the agent actually runs — over ssh, into a sandbox, into a managed runtime —
|
||||
and then syncing changes back when the run finishes. Treating a `git remote`
|
||||
as the source of truth (`git push` from inside the agent, fetch on the next
|
||||
wake) breaks dependent issues that are gated on the local worktree being
|
||||
caught up, and breaks isolated execution workspaces that have no remote
|
||||
configured at all.
|
||||
|
||||
How to apply:
|
||||
|
||||
- Never `git push` from adapter runtime code. Never assume the local worktree
|
||||
has any `git remote` configured. If you need data from the previous run,
|
||||
read it from the local cwd Paperclip handed you.
|
||||
- If your adapter runs the agent on a different host (ssh, sandbox, remote
|
||||
container), use the round-trip helpers in `@paperclipai/adapter-utils`:
|
||||
[`prepareWorkspaceForSshExecution`](../adapter-utils/src/ssh.ts) bundles the
|
||||
local cwd to the remote dir before the run, and
|
||||
[`restoreWorkspaceFromSshExecution`](../adapter-utils/src/ssh.ts) syncs
|
||||
remote-side changes (including new git commits) back into the local cwd
|
||||
after the run. Both run with no `git remote` configured.
|
||||
- If your adapter runs the agent locally, you can read and write the cwd
|
||||
directly — same invariant applies: changes that future runs need must live
|
||||
in the local cwd by the time `execute()` returns.
|
||||
- A failed sync-back is a run-level error. The heartbeat records
|
||||
`workspace_finalize=failed` on the execution workspace, which gates
|
||||
dependent issue wakes until the next successful finalize. Do not swallow
|
||||
restore errors.
|
||||
|
||||
The invariant is pinned by the `no-remote-git contract` case in
|
||||
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../adapter-utils/src/ssh-fixture.test.ts),
|
||||
which asserts that a remote-only commit propagates to the local worktree
|
||||
through `prepareWorkspaceForSshExecution` → `restoreWorkspaceFromSshExecution`
|
||||
with no git remote configured at any point.
|
||||
|
||||
A static check enforces the rule before runtime ever sees it:
|
||||
[`scripts/check-no-git-push.mjs`](../../scripts/check-no-git-push.mjs) scans
|
||||
adapter and runtime source (`packages/adapters/`, `packages/adapter-utils/`,
|
||||
`server/src/`, `cli/src/`) and fails the `policy` CI job if any unapproved
|
||||
`git push` invocation is added. If you are building an operator-configured
|
||||
path that legitimately must push, add a
|
||||
`// paperclip:allow-git-push: <reason>` comment on the line (or the line
|
||||
above) so the opt-in shows up in code review.
|
||||
|
||||
For the architecture-level write-up of cross-run persistence, see
|
||||
[`docs/guides/board-operator/execution-workspaces-and-runtime-services.md`](../../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#cross-run-persistence-no-remote-git-contract).
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AcpRuntimeOptions } from "acpx/runtime";
|
||||
import { createAcpxLocalExecutor } from "./execute.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
@@ -376,6 +377,126 @@ describe("acpx_local runtime skill isolation", () => {
|
||||
expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const runStderrDir = path.join(stateDir, "run-stderr");
|
||||
await fs.mkdir(runStderrDir, { recursive: true });
|
||||
const stderrTail = "claude-agent-acp: SDK init failed (auth missing)";
|
||||
await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8");
|
||||
|
||||
class FakeAcpRuntimeError extends Error {
|
||||
readonly code = "ACP_SESSION_INIT_FAILED";
|
||||
readonly cause: Error;
|
||||
readonly retryable = false;
|
||||
constructor(message: string, cause: Error) {
|
||||
super(message);
|
||||
this.name = "AcpRuntimeError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: () => ({
|
||||
ensureSession: async () => {
|
||||
throw new FakeAcpRuntimeError(
|
||||
"session/new failed: backend rejected initialize",
|
||||
new Error("upstream timeout"),
|
||||
);
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
close: async () => {},
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
},
|
||||
context: {},
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.errorCode).toBe("acpx_session_init_failed");
|
||||
const meta = result.errorMeta ?? {};
|
||||
expect(meta.errorName).toBe("AcpRuntimeError");
|
||||
expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED");
|
||||
expect(meta.causeMessage).toBe("upstream timeout");
|
||||
expect(meta.retryable).toBe(false);
|
||||
expect(typeof meta.stackPreview).toBe("string");
|
||||
expect(meta.phase).toBe("ensure_session");
|
||||
|
||||
const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\""));
|
||||
expect(errorLogLine).toBeTruthy();
|
||||
const errorPayload = JSON.parse(errorLogLine!.text.trim());
|
||||
expect(errorPayload.phase).toBe("ensure_session");
|
||||
expect(errorPayload.errorName).toBe("AcpRuntimeError");
|
||||
expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED");
|
||||
expect(errorPayload.causeMessage).toBe("upstream timeout");
|
||||
expect(errorPayload.childStderrTail).toContain("SDK init failed");
|
||||
|
||||
const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail"));
|
||||
expect(stderrLog).toBeTruthy();
|
||||
expect(stderrLog!.text).toContain(stderrTail);
|
||||
});
|
||||
|
||||
it("writes wrapper that redirects child stderr to a per-run log file", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
|
||||
const runtimeOptions: AcpRuntimeOptions[] = [];
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions.push(options as unknown as AcpRuntimeOptions);
|
||||
return buildRuntime() as never;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-stderr-1",
|
||||
agent: { id: "agent-1", companyId: "company-1" },
|
||||
runtime: {},
|
||||
config: {
|
||||
agent: "custom",
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose);
|
||||
// verbose is scoped to the claude agent (PAPA-388); the custom agent here
|
||||
// should not opt in to ACPX runtime verbose session-event logs.
|
||||
expect(verboseFlags.every((flag) => flag === false)).toBe(true);
|
||||
|
||||
const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
|
||||
const wrapperFile = wrappers.find((name) => name.endsWith(".sh"));
|
||||
expect(wrapperFile).toBeTruthy();
|
||||
const wrapper = await fs.readFile(path.join(stateDir, "wrappers", wrapperFile!), "utf8");
|
||||
expect(wrapper).toContain("stderr_dir=");
|
||||
expect(wrapper).toContain("run-stderr");
|
||||
expect(wrapper).toContain("PAPERCLIP_RUN_ID");
|
||||
expect(wrapper).toContain("tee -a");
|
||||
expect(wrapper).toContain("exec node ./fake-acp.js");
|
||||
});
|
||||
|
||||
it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => {
|
||||
let observedApiKeyDuringStream: string | undefined;
|
||||
const execute = createAcpxLocalExecutor({
|
||||
@@ -422,4 +543,160 @@ describe("acpx_local runtime skill isolation", () => {
|
||||
else process.env.PAPERCLIP_API_KEY = previousApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
|
||||
const { meta } = await runExecutor(
|
||||
{ agent: "claude", stateDir, cwd },
|
||||
{ context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } },
|
||||
);
|
||||
|
||||
const settingsPath = path.join(cwd, ".claude", "settings.local.json");
|
||||
const written = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
|
||||
permissions?: {
|
||||
allow?: unknown;
|
||||
additionalDirectories?: unknown;
|
||||
defaultMode?: unknown;
|
||||
};
|
||||
};
|
||||
expect(written.permissions?.defaultMode).toBe("default");
|
||||
const allow = written.permissions?.allow;
|
||||
expect(Array.isArray(allow)).toBe(true);
|
||||
expect(allow).toContain("Bash(curl:*)");
|
||||
expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`);
|
||||
const additionalDirectories = written.permissions?.additionalDirectories as string[] | undefined;
|
||||
expect(Array.isArray(additionalDirectories)).toBe(true);
|
||||
expect(additionalDirectories).toContain(stateDir);
|
||||
expect(additionalDirectories).toContain(path.join(root, "agent-home"));
|
||||
|
||||
const note = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
|
||||
entry.includes("Paperclip-managed Claude settings"),
|
||||
);
|
||||
expect(note).toBeTruthy();
|
||||
});
|
||||
|
||||
it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(cwd, ".claude", "settings.local.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
statusLine: { type: "command", command: "preserve-me" },
|
||||
permissions: {
|
||||
allow: ["Bash(npm test:*)"],
|
||||
additionalDirectories: ["/Users/example/custom"],
|
||||
defaultMode: "acceptEdits",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await runExecutor(
|
||||
{ agent: "claude", stateDir, cwd },
|
||||
{ context: { paperclipWorkspace: { cwd } } },
|
||||
);
|
||||
|
||||
const written = JSON.parse(
|
||||
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
|
||||
) as {
|
||||
statusLine?: unknown;
|
||||
permissions?: {
|
||||
allow?: string[];
|
||||
additionalDirectories?: string[];
|
||||
defaultMode?: string;
|
||||
};
|
||||
};
|
||||
expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" });
|
||||
expect(written.permissions?.defaultMode).toBe("acceptEdits");
|
||||
expect(written.permissions?.allow).toContain("Bash(npm test:*)");
|
||||
expect(written.permissions?.allow).toContain("Bash(curl:*)");
|
||||
expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom");
|
||||
expect(written.permissions?.additionalDirectories).toContain(stateDir);
|
||||
});
|
||||
|
||||
it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(cwd, ".claude", "settings.local.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { meta } = await runExecutor(
|
||||
{ agent: "claude", stateDir, cwd },
|
||||
{ context: { paperclipWorkspace: { cwd } } },
|
||||
);
|
||||
|
||||
const written = JSON.parse(
|
||||
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
|
||||
) as { permissions?: { defaultMode?: string } };
|
||||
expect(written.permissions?.defaultMode).toBe("default");
|
||||
|
||||
const overrideNote = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
|
||||
entry.includes("overrode user dontAsk"),
|
||||
);
|
||||
expect(overrideNote).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
|
||||
const verboseByAgent: Record<string, boolean | undefined> = {};
|
||||
for (const agent of ["claude", "codex", "custom"] as const) {
|
||||
const runtimeOptions: AcpRuntimeOptions[] = [];
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions.push(options as AcpRuntimeOptions);
|
||||
return buildRuntime() as never;
|
||||
},
|
||||
});
|
||||
const result = await execute({
|
||||
runId: `run-${agent}`,
|
||||
agent: { id: `agent-${agent}`, companyId: "company-1" },
|
||||
runtime: {},
|
||||
config:
|
||||
agent === "custom"
|
||||
? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd }
|
||||
: { agent, stateDir: path.join(root, `state-${agent}`), cwd },
|
||||
context: { paperclipWorkspace: { cwd } },
|
||||
onLog: async () => {},
|
||||
onMeta: async () => {},
|
||||
} as never);
|
||||
expect(result.exitCode).toBe(0);
|
||||
verboseByAgent[agent] = (runtimeOptions[0] as { verbose?: boolean } | undefined)?.verbose;
|
||||
}
|
||||
|
||||
expect(verboseByAgent.claude).toBe(true);
|
||||
expect(verboseByAgent.codex).toBe(false);
|
||||
expect(verboseByAgent.custom).toBe(false);
|
||||
});
|
||||
|
||||
it("does not touch .claude/settings.local.json for the codex agent", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const cwd = path.join(root, "worktree");
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
|
||||
await runExecutor(
|
||||
{ agent: "codex", stateDir, cwd },
|
||||
{ context: { paperclipWorkspace: { cwd } } },
|
||||
);
|
||||
|
||||
expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,8 @@ interface AcpxPreparedRuntime {
|
||||
remoteExecutionIdentity: Record<string, unknown> | null;
|
||||
skillPromptInstructions: string;
|
||||
skillsIdentity: Record<string, unknown>;
|
||||
childStderrLogPath: string | null;
|
||||
paperclipClaudeSettings: PaperclipClaudeSettingsResult | null;
|
||||
}
|
||||
|
||||
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
|
||||
@@ -564,11 +566,105 @@ function buildSessionParams(input: {
|
||||
};
|
||||
}
|
||||
|
||||
interface PaperclipClaudeSettingsResult {
|
||||
filePath: string;
|
||||
allow: string[];
|
||||
additionalDirectories: string[];
|
||||
defaultMode: string;
|
||||
overrodeDontAsk: boolean;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: Array<string | null | undefined>): string[] {
|
||||
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort();
|
||||
}
|
||||
|
||||
// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses
|
||||
// `settingSources: ["user", "project", "local"]`. By writing a per-worktree
|
||||
// `.claude/settings.local.json` we override the user's potentially-restrictive
|
||||
// `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently
|
||||
// denies every non-allowlisted tool and never reaches `canUseTool`), and we
|
||||
// widen the SDK's Read sandbox to include the Paperclip state dirs the agent
|
||||
// needs to talk to its own control plane.
|
||||
async function writePaperclipClaudeSettings(input: {
|
||||
cwd: string;
|
||||
stateDir: string;
|
||||
agentHome: string;
|
||||
companyId: string;
|
||||
}): Promise<PaperclipClaudeSettingsResult> {
|
||||
const filePath = path.join(input.cwd, ".claude", "settings.local.json");
|
||||
const instanceRoot = defaultPaperclipInstanceDir();
|
||||
const companyRoot = path.join(instanceRoot, "companies", input.companyId);
|
||||
const paperclipAdditionalDirectories = uniqueSorted([
|
||||
input.stateDir,
|
||||
input.agentHome,
|
||||
companyRoot,
|
||||
]);
|
||||
const paperclipAllow = uniqueSorted([
|
||||
"Bash(curl:*)",
|
||||
"Bash(env:*)",
|
||||
"Bash(env)",
|
||||
`Bash(${input.cwd}/scripts/paperclip-issue-update.sh:*)`,
|
||||
`Bash(${input.cwd}/scripts/paperclip:*)`,
|
||||
]);
|
||||
|
||||
let existing: Record<string, unknown> = {};
|
||||
const existingRaw = await fs.readFile(filePath, "utf8").catch(() => null);
|
||||
if (existingRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(existingRaw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// Malformed settings file — leave it alone in `existing` and our merge will replace it with a valid one.
|
||||
}
|
||||
}
|
||||
const existingPerms =
|
||||
existing.permissions && typeof existing.permissions === "object" && !Array.isArray(existing.permissions)
|
||||
? (existing.permissions as Record<string, unknown>)
|
||||
: {};
|
||||
const existingAllow = Array.isArray(existingPerms.allow)
|
||||
? (existingPerms.allow as unknown[]).filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
const existingAdditionalDirectories = Array.isArray(existingPerms.additionalDirectories)
|
||||
? (existingPerms.additionalDirectories as unknown[]).filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
const mergedAllow = uniqueSorted([...existingAllow, ...paperclipAllow]);
|
||||
const mergedAdditionalDirectories = uniqueSorted([
|
||||
...existingAdditionalDirectories,
|
||||
...paperclipAdditionalDirectories,
|
||||
]);
|
||||
const existingDefaultMode =
|
||||
typeof existingPerms.defaultMode === "string" ? (existingPerms.defaultMode as string) : "";
|
||||
const defaultMode =
|
||||
existingDefaultMode && existingDefaultMode !== "dontAsk" ? existingDefaultMode : "default";
|
||||
const overrodeDontAsk = existingDefaultMode === "dontAsk";
|
||||
|
||||
const nextPermissions: Record<string, unknown> = {
|
||||
...existingPerms,
|
||||
allow: mergedAllow,
|
||||
additionalDirectories: mergedAdditionalDirectories,
|
||||
defaultMode,
|
||||
};
|
||||
const next: Record<string, unknown> = { ...existing, permissions: nextPermissions };
|
||||
await writeFileAtomically({
|
||||
target: filePath,
|
||||
contents: `${JSON.stringify(next, null, 2)}\n`,
|
||||
mode: 0o600,
|
||||
});
|
||||
return {
|
||||
filePath,
|
||||
allow: mergedAllow,
|
||||
additionalDirectories: mergedAdditionalDirectories,
|
||||
defaultMode,
|
||||
overrodeDontAsk,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeAgentWrapper(input: {
|
||||
stateDir: string;
|
||||
acpxAgent: string;
|
||||
agentCommandShell: string;
|
||||
env: Record<string, string>;
|
||||
childStderrDir: string;
|
||||
}): Promise<{ wrapperPath: string; envFilePath: string }> {
|
||||
const wrappersDir = path.join(input.stateDir, "wrappers");
|
||||
await fs.mkdir(wrappersDir, { recursive: true });
|
||||
@@ -580,6 +676,7 @@ async function writeAgentWrapper(input: {
|
||||
agent: input.acpxAgent,
|
||||
command: input.agentCommandShell,
|
||||
env: envLines,
|
||||
childStderrDir: input.childStderrDir,
|
||||
});
|
||||
const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`);
|
||||
const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`);
|
||||
@@ -592,6 +689,11 @@ async function writeAgentWrapper(input: {
|
||||
" source \"$env_file\"",
|
||||
" set +a",
|
||||
"fi",
|
||||
`stderr_dir=${shellQuote(input.childStderrDir)}`,
|
||||
"if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then",
|
||||
" mkdir -p \"$stderr_dir\"",
|
||||
" exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)",
|
||||
"fi",
|
||||
`exec ${input.agentCommandShell} "$@"`,
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -723,10 +825,20 @@ async function buildRuntime(input: {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken;
|
||||
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
|
||||
// via session/set_config_option — the ACP server's set_config_option handler
|
||||
// validates the value against its internal available-models list and rejects
|
||||
// bare model IDs (e.g. "claude-opus-4-7") that don't exactly match a model
|
||||
// entry in some versions. ANTHROPIC_MODEL is read during initialization, so
|
||||
// it reliably sets the model before any turns are run.
|
||||
if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) {
|
||||
env.ANTHROPIC_MODEL = requestedModel;
|
||||
}
|
||||
|
||||
let skillPromptInstructions = "";
|
||||
let skillsIdentity: Record<string, unknown> = { mode: "unsupported" };
|
||||
const skillCommandNotes: string[] = [];
|
||||
let paperclipClaudeSettings: PaperclipClaudeSettingsResult | null = null;
|
||||
if (acpxAgent === "claude") {
|
||||
const preparedSkills = await prepareClaudeSkillRuntime({
|
||||
stateDir,
|
||||
@@ -736,6 +848,17 @@ async function buildRuntime(input: {
|
||||
skillPromptInstructions = preparedSkills.promptInstructions;
|
||||
skillsIdentity = preparedSkills.identity;
|
||||
skillCommandNotes.push(...preparedSkills.commandNotes);
|
||||
paperclipClaudeSettings = await writePaperclipClaudeSettings({
|
||||
cwd,
|
||||
stateDir,
|
||||
agentHome,
|
||||
companyId: agent.companyId,
|
||||
});
|
||||
skillCommandNotes.push(
|
||||
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
|
||||
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
|
||||
}, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`,
|
||||
);
|
||||
} else if (acpxAgent === "codex") {
|
||||
const preparedSkills = await prepareCodexSkillRuntime({
|
||||
companyId: agent.companyId,
|
||||
@@ -757,12 +880,15 @@ async function buildRuntime(input: {
|
||||
const builtInCommand = resolveBuiltInAgentCommand(acpxAgent);
|
||||
const agentCommand = configuredCommand || builtInCommand || null;
|
||||
const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : "");
|
||||
const childStderrDir = path.join(stateDir, "run-stderr");
|
||||
const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null;
|
||||
const wrapper = agentCommand
|
||||
? await writeAgentWrapper({
|
||||
stateDir,
|
||||
acpxAgent,
|
||||
agentCommandShell,
|
||||
env,
|
||||
childStderrDir,
|
||||
})
|
||||
: null;
|
||||
const wrapperPath = wrapper?.wrapperPath ?? null;
|
||||
@@ -781,6 +907,13 @@ async function buildRuntime(input: {
|
||||
remoteExecutionIdentity,
|
||||
skillsIdentity,
|
||||
skillPromptInstructions,
|
||||
paperclipClaudeSettings: paperclipClaudeSettings
|
||||
? {
|
||||
allow: paperclipClaudeSettings.allow,
|
||||
additionalDirectories: paperclipClaudeSettings.additionalDirectories,
|
||||
defaultMode: paperclipClaudeSettings.defaultMode,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
|
||||
const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
|
||||
@@ -817,12 +950,19 @@ async function buildRuntime(input: {
|
||||
...skillsIdentity,
|
||||
commandNotes: skillCommandNotes,
|
||||
},
|
||||
childStderrLogPath,
|
||||
paperclipClaudeSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
|
||||
const options: Array<{ key: string; value: string }> = [];
|
||||
if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel });
|
||||
// Model for the claude agent is pre-set via ANTHROPIC_MODEL env var at
|
||||
// startup; skip set_config_option to avoid ACP-server model-name validation
|
||||
// that rejects bare IDs like "claude-opus-4-7" in some runtime versions.
|
||||
if (prepared.requestedModel && prepared.acpxAgent !== "claude") {
|
||||
options.push({ key: "model", value: prepared.requestedModel });
|
||||
}
|
||||
if (prepared.requestedThinkingEffort) {
|
||||
options.push({
|
||||
key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort",
|
||||
@@ -999,33 +1139,151 @@ function resultErrorMessage(result: AcpRuntimeTurnResult): string | null {
|
||||
return result.error.message;
|
||||
}
|
||||
|
||||
function classifyError(err: unknown): Pick<AdapterExecutionResult, "errorCode" | "errorMeta"> {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
type AcpxExecutionPhase = "ensure_session" | "configure_session" | "turn";
|
||||
|
||||
function describeErrorDiagnostics(err: unknown): {
|
||||
errorName: string;
|
||||
acpCode: string | null;
|
||||
causeMessage: string | null;
|
||||
retryable: boolean | null;
|
||||
stackPreview: string | null;
|
||||
} {
|
||||
const errorName =
|
||||
err instanceof Error ? err.name || err.constructor.name : typeof err;
|
||||
const maybeCode =
|
||||
err && typeof err === "object" && typeof (err as { code?: unknown }).code === "string"
|
||||
? (err as { code: string }).code
|
||||
: null;
|
||||
const acpCode = isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null;
|
||||
const acpCode =
|
||||
isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null;
|
||||
const cause =
|
||||
err && typeof err === "object" && (err as { cause?: unknown }).cause !== undefined
|
||||
? (err as { cause?: unknown }).cause
|
||||
: undefined;
|
||||
const causeMessage =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: typeof cause === "string"
|
||||
? cause
|
||||
: null;
|
||||
const retryable =
|
||||
err && typeof err === "object" && typeof (err as { retryable?: unknown }).retryable === "boolean"
|
||||
? (err as { retryable: boolean }).retryable
|
||||
: null;
|
||||
const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : "";
|
||||
const stackPreview = stack ? stack.split("\n").slice(0, 6).join("\n") : null;
|
||||
return { errorName, acpCode, causeMessage, retryable, stackPreview };
|
||||
}
|
||||
|
||||
function classifyError(
|
||||
err: unknown,
|
||||
phase?: AcpxExecutionPhase,
|
||||
): Pick<AdapterExecutionResult, "errorCode" | "errorMeta"> {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const diagnostics = describeErrorDiagnostics(err);
|
||||
const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics;
|
||||
const baseMeta: Record<string, unknown> = {
|
||||
errorName,
|
||||
...(acpCode ? { acpCode } : {}),
|
||||
...(causeMessage ? { causeMessage } : {}),
|
||||
...(retryable !== null ? { retryable } : {}),
|
||||
...(stackPreview ? { stackPreview } : {}),
|
||||
...(phase ? { phase } : {}),
|
||||
};
|
||||
const lower = message.toLowerCase();
|
||||
const authLike = lower.includes("auth") || lower.includes("login") || lower.includes("credential");
|
||||
if (authLike) {
|
||||
return {
|
||||
errorCode: "acpx_auth_required",
|
||||
errorMeta: { category: "auth", ...(acpCode ? { acpCode } : {}) },
|
||||
errorMeta: { category: "auth", ...baseMeta },
|
||||
};
|
||||
}
|
||||
const phaseCode = (() => {
|
||||
if (acpCode === "ACP_SESSION_INIT_FAILED") return "acpx_session_init_failed";
|
||||
if (acpCode === "ACP_TURN_FAILED") return "acpx_turn_failed";
|
||||
if (acpCode === "ACP_BACKEND_MISSING") return "acpx_backend_missing";
|
||||
if (acpCode === "ACP_BACKEND_UNAVAILABLE") return "acpx_backend_unavailable";
|
||||
if (phase === "ensure_session") return "acpx_session_init_failed";
|
||||
if (phase === "configure_session") return "acpx_session_config_failed";
|
||||
if (phase === "turn") return "acpx_turn_failed";
|
||||
return null;
|
||||
})();
|
||||
if (phaseCode) {
|
||||
return {
|
||||
errorCode: phaseCode,
|
||||
errorMeta: { category: acpCode ? "protocol" : "runtime", ...baseMeta },
|
||||
};
|
||||
}
|
||||
if (acpCode) {
|
||||
return {
|
||||
errorCode: "acpx_protocol_error",
|
||||
errorMeta: { category: "protocol", acpCode },
|
||||
errorMeta: { category: "protocol", ...baseMeta },
|
||||
};
|
||||
}
|
||||
return {
|
||||
errorCode: "acpx_runtime_error",
|
||||
errorMeta: { category: "runtime" },
|
||||
errorMeta: { category: "runtime", ...baseMeta },
|
||||
};
|
||||
}
|
||||
|
||||
async function readChildStderrTail(input: {
|
||||
logPath: string | null;
|
||||
maxBytes?: number;
|
||||
}): Promise<string | null> {
|
||||
if (!input.logPath) return null;
|
||||
const maxBytes = input.maxBytes ?? 4096;
|
||||
let handle: fs.FileHandle | null = null;
|
||||
try {
|
||||
const stat = await fs.stat(input.logPath);
|
||||
if (stat.size === 0) return null;
|
||||
handle = await fs.open(input.logPath, "r");
|
||||
const readBytes = Math.min(stat.size, maxBytes);
|
||||
const buffer = Buffer.alloc(readBytes);
|
||||
await handle.read(buffer, 0, readBytes, Math.max(0, stat.size - readBytes));
|
||||
const tail = buffer.toString("utf8").trim();
|
||||
return tail.length > 0 ? tail : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function emitAcpxFailure(input: {
|
||||
ctx: AdapterExecutionContext;
|
||||
prepared: AcpxPreparedRuntime;
|
||||
err: unknown;
|
||||
phase: AcpxExecutionPhase;
|
||||
// Replace the err-derived message in both the stderr-tail log header and the
|
||||
// acpx.error payload. Used by the turn path to surface "Timed out after Ns"
|
||||
// instead of the raw underlying error message.
|
||||
messageOverride?: string;
|
||||
}): Promise<{
|
||||
classified: Pick<AdapterExecutionResult, "errorCode" | "errorMeta">;
|
||||
message: string;
|
||||
childStderrTail: string | null;
|
||||
}> {
|
||||
const { ctx, prepared, err, phase, messageOverride } = input;
|
||||
const rawMessage = err instanceof Error ? err.message : String(err);
|
||||
const message = messageOverride ?? rawMessage;
|
||||
const classified = classifyError(err, phase);
|
||||
const childStderrTail = await readChildStderrTail({ logPath: prepared.childStderrLogPath });
|
||||
if (childStderrTail) {
|
||||
await ctx.onLog(
|
||||
"stderr",
|
||||
`[paperclip] ACPX child stderr tail (${phase}):\n${childStderrTail}\n`,
|
||||
);
|
||||
}
|
||||
await emitAcpxLog(ctx, {
|
||||
type: "acpx.error",
|
||||
message,
|
||||
phase,
|
||||
...classified.errorMeta,
|
||||
...(childStderrTail ? { childStderrTail } : {}),
|
||||
});
|
||||
return { classified, message, childStderrTail };
|
||||
}
|
||||
|
||||
function isResumeFailure(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return /resume|load|not found|no session|unknown session|conversation/i.test(message);
|
||||
@@ -1136,6 +1394,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
permissionMode: prepared.permissionMode,
|
||||
nonInteractivePermissions: prepared.nonInteractivePermissions,
|
||||
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
||||
// Scope ACPX runtime verbose logs to the claude agent only — that's the
|
||||
// surface we know needs the extra session-event detail (PAPA-388). codex
|
||||
// and custom agents already emit their own per-tool output and don't
|
||||
// benefit from doubling the log volume.
|
||||
verbose: prepared.acpxAgent === "claude",
|
||||
};
|
||||
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
|
||||
if (cached) clearWarmHandleTimer(cached);
|
||||
@@ -1177,9 +1440,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const classified = classifyError(err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
||||
const { classified, message } = await emitAcpxFailure({
|
||||
ctx,
|
||||
prepared,
|
||||
err,
|
||||
phase: "ensure_session",
|
||||
});
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
@@ -1216,9 +1482,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
onLog: ctx.onLog,
|
||||
});
|
||||
} catch (err) {
|
||||
const classified = classifyError(err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
||||
const { classified, message } = await emitAcpxFailure({
|
||||
ctx,
|
||||
prepared,
|
||||
err,
|
||||
phase: "configure_session",
|
||||
});
|
||||
await runtime.close({
|
||||
handle: sessionHandle,
|
||||
reason: "paperclip config cleanup",
|
||||
@@ -1271,7 +1540,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
commandNotes: [
|
||||
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
||||
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
|
||||
...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []),
|
||||
...(prepared.requestedModel
|
||||
? [
|
||||
prepared.acpxAgent === "claude"
|
||||
? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).`
|
||||
: `Requested ACPX model: ${prepared.requestedModel}.`,
|
||||
]
|
||||
: []),
|
||||
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
|
||||
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
|
||||
...(Array.isArray(prepared.skillsIdentity.commandNotes)
|
||||
@@ -1414,10 +1689,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
};
|
||||
} catch (err) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
const classified = classifyError(err);
|
||||
const message = timedOut ? `Timed out after ${prepared.timeoutSec}s` : err instanceof Error ? err.message : String(err);
|
||||
const messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined;
|
||||
const cancel = cancelActiveTurn as ((reason: string) => Promise<void>) | null;
|
||||
if (cancel) await cancel(message).catch(() => {});
|
||||
const preEmitMessage =
|
||||
messageOverride ?? (err instanceof Error ? err.message : String(err));
|
||||
if (cancel) await cancel(preEmitMessage).catch(() => {});
|
||||
await runtime.close({
|
||||
handle: sessionHandle,
|
||||
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
|
||||
@@ -1428,7 +1704,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
||||
clearWarmHandleTimer(existing);
|
||||
warmHandles.delete(prepared.sessionKey);
|
||||
}
|
||||
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
||||
const { classified, message } = await emitAcpxFailure({
|
||||
ctx,
|
||||
prepared,
|
||||
err,
|
||||
phase: "turn",
|
||||
messageOverride,
|
||||
});
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: timedOut ? "SIGTERM" : null,
|
||||
|
||||
@@ -2,10 +2,10 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildRuntimeMountedSkillSnapshot,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -35,9 +35,7 @@ function unsupportedDetail(): string {
|
||||
async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const acpxAgent = normalizeAcpxSkillAgent(config);
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const supported = acpxAgent !== "custom";
|
||||
const warnings: string[] = supported
|
||||
? []
|
||||
@@ -45,53 +43,16 @@ async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<
|
||||
"Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.",
|
||||
];
|
||||
|
||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => {
|
||||
const desired = desiredSet.has(entry.key);
|
||||
return {
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: desired ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desired ? (supported ? configuredDetail(acpxAgent) : unsupportedDetail()) : null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
return buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "acpx_local",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
supported,
|
||||
mode: supported ? "ephemeral" : "unsupported",
|
||||
desiredSkills,
|
||||
entries,
|
||||
configuredDetail: configuredDetail(acpxAgent),
|
||||
unsupportedDetail: unsupportedDetail(),
|
||||
warnings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAcpxSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
|
||||
@@ -6,6 +6,7 @@ export const label = "Claude Code (local)";
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
|
||||
|
||||
export const models = [
|
||||
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js";
|
||||
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
|
||||
export { listClaudeModels } from "./models.js";
|
||||
export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseClaudeStreamJson,
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
import { models as DIRECT_MODELS } from "../index.js";
|
||||
|
||||
const ANTHROPIC_MODELS_ENDPOINT = "/v1/models";
|
||||
const ANTHROPIC_MODELS_TIMEOUT_MS = 5000;
|
||||
const ANTHROPIC_MODELS_CACHE_TTL_MS = 60_000;
|
||||
const ANTHROPIC_API_VERSION = "2023-06-01";
|
||||
|
||||
/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */
|
||||
const BEDROCK_MODELS: AdapterModel[] = [
|
||||
{ id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" },
|
||||
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
|
||||
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
|
||||
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
|
||||
];
|
||||
|
||||
let cached: { keyFingerprint: string; baseUrl: string; expiresAt: number; models: AdapterModel[] } | null = null;
|
||||
|
||||
function isBedrockEnv(): boolean {
|
||||
return (
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
|
||||
@@ -17,13 +26,134 @@ function isBedrockEnv(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprint(apiKey: string): string {
|
||||
const digest = createHash("sha256").update(apiKey).digest("base64url").slice(0, 16);
|
||||
return `${apiKey.length}:${digest}`;
|
||||
}
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function mergedWithFallback(models: AdapterModel[]): AdapterModel[] {
|
||||
return dedupeModels([
|
||||
...models,
|
||||
...DIRECT_MODELS,
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveAnthropicApiKey(): string | null {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
return apiKey && apiKey.length > 0 ? apiKey : null;
|
||||
}
|
||||
|
||||
function resolveAnthropicBaseUrl(): string {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL?.trim();
|
||||
return baseUrl && baseUrl.length > 0 ? baseUrl.replace(/\/+$/, "") : "https://api.anthropic.com";
|
||||
}
|
||||
|
||||
async function fetchAnthropicModels(apiKey: string, baseUrl: string): Promise<AdapterModel[]> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ANTHROPIC_MODELS_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${ANTHROPIC_MODELS_ENDPOINT}`, {
|
||||
headers: {
|
||||
"anthropic-version": ANTHROPIC_API_VERSION,
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||
const models: AdapterModel[] = [];
|
||||
for (const item of data) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
const record = item as { id?: unknown; display_name?: unknown };
|
||||
if (typeof record.id !== "string" || record.id.trim().length === 0) continue;
|
||||
const displayName =
|
||||
typeof record.display_name === "string" && record.display_name.trim().length > 0
|
||||
? record.display_name
|
||||
: record.id;
|
||||
models.push({
|
||||
id: record.id,
|
||||
label: displayName,
|
||||
});
|
||||
}
|
||||
return dedupeModels(models);
|
||||
} catch (error) {
|
||||
console.warn("[paperclip] Claude model discovery failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClaudeModels(options?: { forceRefresh?: boolean }): Promise<AdapterModel[]> {
|
||||
if (isBedrockEnv()) return dedupeModels(BEDROCK_MODELS);
|
||||
|
||||
const fallback = dedupeModels(DIRECT_MODELS);
|
||||
const apiKey = resolveAnthropicApiKey();
|
||||
if (!apiKey) return fallback;
|
||||
|
||||
const now = Date.now();
|
||||
const baseUrl = resolveAnthropicBaseUrl();
|
||||
const keyFingerprint = fingerprint(apiKey);
|
||||
if (
|
||||
options?.forceRefresh !== true &&
|
||||
cached &&
|
||||
cached.keyFingerprint === keyFingerprint &&
|
||||
cached.baseUrl === baseUrl &&
|
||||
cached.expiresAt > now
|
||||
) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const fetched = await fetchAnthropicModels(apiKey, baseUrl);
|
||||
if (fetched.length > 0) {
|
||||
const merged = mergedWithFallback(fetched);
|
||||
cached = {
|
||||
keyFingerprint,
|
||||
baseUrl,
|
||||
expiresAt: now + ANTHROPIC_MODELS_CACHE_TTL_MS,
|
||||
models: merged,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
if (cached && cached.keyFingerprint === keyFingerprint && cached.baseUrl === baseUrl && cached.models.length > 0) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the model list appropriate for the current auth mode.
|
||||
* When Bedrock env vars are detected, returns Bedrock-native model IDs;
|
||||
* otherwise returns standard Anthropic API model IDs.
|
||||
*/
|
||||
export async function listClaudeModels(): Promise<AdapterModel[]> {
|
||||
return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
|
||||
return loadClaudeModels();
|
||||
}
|
||||
|
||||
export async function refreshClaudeModels(): Promise<AdapterModel[]> {
|
||||
return loadClaudeModels({ forceRefresh: true });
|
||||
}
|
||||
|
||||
export function resetClaudeModelsCacheForTests() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** Check whether a model ID is a Bedrock-native identifier (not an Anthropic API short name). */
|
||||
|
||||
@@ -3,10 +3,10 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildRuntimeMountedSkillSnapshot,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
@@ -30,76 +30,19 @@ function resolveClaudeSkillsHome(config: Record<string, unknown>) {
|
||||
|
||||
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveClaudeSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired: desiredSet.has(entry.key),
|
||||
managed: true,
|
||||
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.key)
|
||||
? "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run."
|
||||
: null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: undefined,
|
||||
targetPath: undefined,
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||
entries.push({
|
||||
key: name,
|
||||
runtimeName: name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: "~/.claude/skills",
|
||||
readOnly: true,
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: "Installed outside Paperclip management in the Claude skills home.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
return buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
configuredDetail: "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run.",
|
||||
externalInstalled: installed,
|
||||
externalLocationLabel: "~/.claude/skills",
|
||||
externalDetail: "Installed outside Paperclip management in the Claude skills home.",
|
||||
skillsHome,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listClaudeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
|
||||
@@ -212,6 +212,14 @@ export async function testEnvironment(
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
// Sandbox bridges still add lease warmup and transport overhead, but
|
||||
// the standard-2 Cloudflare tier now probes fast enough that a 90s
|
||||
// budget leaves headroom without masking real hangs.
|
||||
const helloProbeTimeoutSec = Math.max(
|
||||
1,
|
||||
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45),
|
||||
);
|
||||
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
@@ -220,7 +228,7 @@ export async function testEnvironment(
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
timeoutSec: helloProbeTimeoutSec,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
|
||||
@@ -52,7 +52,8 @@ export const modelProfiles: AdapterModelProfileDefinition[] = [
|
||||
description: "Use the lowest-cost known Codex local model lane without changing the primary model.",
|
||||
adapterConfig: {
|
||||
model: "gpt-5.3-codex-spark",
|
||||
modelReasoningEffort: "low",
|
||||
// Spark is the cheap lane by model price; high effort keeps Codex coding behavior usable for delegated work.
|
||||
modelReasoningEffort: "high",
|
||||
},
|
||||
source: "adapter_default",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { prepareManagedCodexHome } from "./codex-home.js";
|
||||
|
||||
describe("codex managed home", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("treats a concurrently-created expected auth symlink as success", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
|
||||
const sharedCodexHome = path.join(root, "shared-codex-home");
|
||||
const paperclipHome = path.join(root, "paperclip-home");
|
||||
const managedCodexHome = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"codex-home",
|
||||
);
|
||||
const sharedAuth = path.join(sharedCodexHome, "auth.json");
|
||||
const managedAuth = path.join(managedCodexHome, "auth.json");
|
||||
|
||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||
await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8");
|
||||
|
||||
const originalSymlink = fs.symlink.bind(fs);
|
||||
vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => {
|
||||
await originalSymlink(source, target, type);
|
||||
const error = new Error("file already exists") as NodeJS.ErrnoException;
|
||||
error.code = "EEXIST";
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
prepareManagedCodexHome(
|
||||
{
|
||||
CODEX_HOME: sharedCodexHome,
|
||||
PAPERCLIP_HOME: paperclipHome,
|
||||
PAPERCLIP_INSTANCE_ID: "default",
|
||||
},
|
||||
async () => {},
|
||||
"company-1",
|
||||
),
|
||||
).resolves.toBe(managedCodexHome);
|
||||
|
||||
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth));
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
}
|
||||
|
||||
async function isExpectedSymlink(target: string, source: string): Promise<boolean> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing?.isSymbolicLink()) return false;
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) return false;
|
||||
|
||||
return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
|
||||
}
|
||||
|
||||
async function createExpectedSymlink(target: string, source: string): Promise<void> {
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "EEXIST" && await isExpectedSymlink(target, source)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSymlink(target: string, source: string): Promise<void> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing) {
|
||||
await ensureParentDir(target);
|
||||
await fs.symlink(source, target);
|
||||
await createExpectedSymlink(target, source);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) return;
|
||||
|
||||
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||
if (resolvedLinkedPath === source) return;
|
||||
if (await isExpectedSymlink(target, source)) return;
|
||||
|
||||
await fs.unlink(target);
|
||||
await fs.symlink(source, target);
|
||||
await createExpectedSymlink(target, source);
|
||||
}
|
||||
|
||||
async function ensureCopiedFile(target: string, source: string): Promise<void> {
|
||||
|
||||
@@ -2,10 +2,10 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildRuntimeMountedSkillSnapshot,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -16,56 +16,13 @@ async function buildCodexSkillSnapshot(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired: desiredSet.has(entry.key),
|
||||
managed: true,
|
||||
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.key)
|
||||
? "Will be linked into the effective CODEX_HOME/skills/ directory on the next run."
|
||||
: null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
return buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "codex_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
configuredDetail: "Will be linked into the effective CODEX_HOME/skills/ directory on the next run.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
@@ -98,6 +99,7 @@ export async function testEnvironment(
|
||||
let command = asString(config.command, "agent");
|
||||
const target = ctx.executionTarget ?? null;
|
||||
const targetIsRemote = target?.kind === "remote";
|
||||
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
|
||||
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
|
||||
const targetLabel = targetIsRemote
|
||||
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
|
||||
@@ -230,6 +232,12 @@ export async function testEnvironment(
|
||||
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
// Cursor's `agent` binary still pays cold-start overhead in container
|
||||
// sandboxes, but standard-2 probes no longer need a 120s version budget.
|
||||
const versionProbeTimeoutSec = Math.max(
|
||||
1,
|
||||
asNumber(config.versionProbeTimeoutSec, targetIsSandbox ? 60 : 45),
|
||||
);
|
||||
const versionProbe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
@@ -238,7 +246,7 @@ export async function testEnvironment(
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
timeoutSec: versionProbeTimeoutSec,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
@@ -295,6 +303,12 @@ export async function testEnvironment(
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
// Sandbox bridges still add cursor CLI cold-start overhead, but the
|
||||
// standard-2 tier now completes probes fast enough that 90s is ample.
|
||||
const helloProbeTimeoutSec = Math.max(
|
||||
1,
|
||||
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45),
|
||||
);
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
@@ -303,7 +317,7 @@ export async function testEnvironment(
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
timeoutSec: helloProbeTimeoutSec,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-grok-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/grok-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { printGrokStreamEvent } from "./format-event.js";
|
||||
|
||||
describe("printGrokStreamEvent", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
spy.mockClear();
|
||||
});
|
||||
|
||||
it("prints thought/text/end events", () => {
|
||||
printGrokStreamEvent(JSON.stringify({ type: "thought", data: "Plan" }), false);
|
||||
printGrokStreamEvent(JSON.stringify({ type: "text", data: "hello" }), false);
|
||||
printGrokStreamEvent(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), false);
|
||||
|
||||
expect(spy.mock.calls.flat()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("thinking: Plan"),
|
||||
expect.stringContaining("assistant: hello"),
|
||||
expect.stringContaining("Grok run completed"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
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 asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
export function printGrokStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type).trim();
|
||||
if (type === "thought") {
|
||||
const text = asString(parsed.data);
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const text = asString(parsed.data);
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "end") {
|
||||
const stopReason = asString(parsed.stopReason);
|
||||
const sessionId = asString(parsed.sessionId);
|
||||
const details = [stopReason ? `stopReason=${stopReason}` : "", sessionId ? `session=${sessionId}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
console.log(pc.blue(`Grok run completed${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text =
|
||||
asString(parsed.data) ||
|
||||
asString(parsed.message) ||
|
||||
asString(parsed.error) ||
|
||||
"Grok error";
|
||||
console.log(pc.red(`error: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = asRecord(parsed);
|
||||
console.log(pc.gray(`event: ${type || "unknown"} ${payload ? JSON.stringify(payload) : line}`));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printGrokStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,45 @@
|
||||
export const type = "grok_local";
|
||||
export const label = "Grok Build (local)";
|
||||
|
||||
export const DEFAULT_GROK_LOCAL_MODEL = "grok-build";
|
||||
|
||||
export const models = [
|
||||
{ id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# grok_local agent configuration
|
||||
|
||||
Adapter: grok_local
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run the native Grok Build CLI locally on the host machine
|
||||
- You want resumable Grok sessions across heartbeats via \`--resume\`
|
||||
- You want Paperclip-managed instructions and skills staged into the execution workspace using Grok's native discovery paths (\`Agents.md\` and \`.claude/skills\`)
|
||||
|
||||
Don't use when:
|
||||
- You need a webhook-style external invocation (use http or openclaw_gateway)
|
||||
- You only need a one-shot script without an AI coding agent loop (use process)
|
||||
- Grok CLI is not installed or authenticated on the machine that runs Paperclip
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file. Paperclip stages it into the execution workspace as \`Agents.md\` when safe, otherwise falls back to \`--rules @file\`
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Grok model id. Defaults to grok-build.
|
||||
- permissionMode (string, optional): Grok permission mode. Defaults to \`dontAsk\`
|
||||
- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\`
|
||||
- maxTurns (number, optional): maximum agent turns for the run
|
||||
- command (string, optional): defaults to "grok"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Runs use \`grok --single\` with \`--output-format streaming-json\`.
|
||||
- Sessions resume with \`--resume <sessionId>\` when the saved session cwd matches the current cwd.
|
||||
- Paperclip stages desired runtime skills into \`.claude/skills\` inside the execution workspace so Grok discovers them as project skills.
|
||||
- Use \`grok models\` to inspect authentication and available models on the host.
|
||||
`;
|
||||
@@ -0,0 +1,187 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({
|
||||
workspaceRemoteDir: null,
|
||||
restoreWorkspace: async () => {},
|
||||
})));
|
||||
const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok"));
|
||||
const runProcessMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
|
||||
adapterExecutionTargetIsRemote: () => false,
|
||||
adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd,
|
||||
overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target,
|
||||
adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }),
|
||||
adapterExecutionTargetSessionMatches: () => true,
|
||||
describeAdapterExecutionTarget: () => "local",
|
||||
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock,
|
||||
prepareAdapterExecutionTargetRuntime: prepareRuntimeMock,
|
||||
readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" },
|
||||
resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock,
|
||||
resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec,
|
||||
runAdapterExecutionTargetProcess: runProcessMock,
|
||||
}));
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeTempRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-local-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string): Promise<boolean> {
|
||||
return fs.access(candidate).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
describe("grok_local execute", () => {
|
||||
beforeEach(() => {
|
||||
ensureRuntimeInstalledMock.mockClear();
|
||||
ensureCommandMock.mockClear();
|
||||
prepareRuntimeMock.mockClear();
|
||||
resolveCommandForLogsMock.mockClear();
|
||||
runProcessMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("stages Grok-native instructions and skills into the workspace for the run and cleans them up afterward", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const instructionsPath = path.join(root, "managed", "AGENTS.md");
|
||||
const skillSource = path.join(root, "runtime-skills", "paperclip");
|
||||
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
|
||||
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
|
||||
await fs.mkdir(skillSource, { recursive: true });
|
||||
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
|
||||
|
||||
runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => {
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--output-format",
|
||||
"streaming-json",
|
||||
"--always-approve",
|
||||
"--permission-mode",
|
||||
"dontAsk",
|
||||
]),
|
||||
);
|
||||
expect(await fs.readFile(path.join(root, "Agents.md"), "utf8")).toContain("You are Grok.");
|
||||
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip", "SKILL.md"))).toBe(true);
|
||||
await options.onLog?.("stdout", '{"type":"text","data":"done"}\n');
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "text", data: "done" }),
|
||||
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
||||
const ctx: AdapterExecutionContext = {
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Grok Agent",
|
||||
adapterType: "grok_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
cwd: root,
|
||||
instructionsFilePath: instructionsPath,
|
||||
paperclipRuntimeSkills: [{
|
||||
key: "paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: skillSource,
|
||||
required: false,
|
||||
}],
|
||||
paperclipSkillSync: { desiredSkills: ["paperclip"] },
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-token",
|
||||
onLog: async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
logs.push({ stream, chunk });
|
||||
},
|
||||
};
|
||||
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 0,
|
||||
errorMessage: null,
|
||||
summary: "done",
|
||||
sessionId: "sess-1",
|
||||
sessionDisplayId: "sess-1",
|
||||
});
|
||||
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
|
||||
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
|
||||
expect(logs.map((entry) => entry.chunk)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans up staged assets when setup fails before the Grok process starts", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const instructionsPath = path.join(root, "managed", "AGENTS.md");
|
||||
const skillSource = path.join(root, "runtime-skills", "paperclip");
|
||||
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
|
||||
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
|
||||
await fs.mkdir(skillSource, { recursive: true });
|
||||
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
|
||||
ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed"));
|
||||
|
||||
const ctx: AdapterExecutionContext = {
|
||||
runId: "run-setup-fail",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Grok Agent",
|
||||
adapterType: "grok_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
cwd: root,
|
||||
instructionsFilePath: instructionsPath,
|
||||
paperclipRuntimeSkills: [{
|
||||
key: "paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: skillSource,
|
||||
required: false,
|
||||
}],
|
||||
paperclipSkillSync: { desiredSkills: ["paperclip"] },
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-token",
|
||||
onLog: async () => {},
|
||||
};
|
||||
|
||||
await expect(execute(ctx)).rejects.toThrow("grok not installed");
|
||||
expect(runProcessMock).not.toHaveBeenCalled();
|
||||
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
|
||||
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,583 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
overrideAdapterExecutionTargetRemoteCwd,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
resolveAdapterExecutionTargetTimeoutSec,
|
||||
runAdapterExecutionTargetProcess,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
buildInvocationEnvForLogs,
|
||||
buildPaperclipEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensurePathInEnv,
|
||||
joinPromptSections,
|
||||
materializePaperclipSkillCopy,
|
||||
parseObject,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
stringifyPaperclipWakePayload,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
const paperclipKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort();
|
||||
if (paperclipKeys.length === 0) return "";
|
||||
return [
|
||||
"Paperclip runtime note:",
|
||||
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderApiAccessNote(env: Record<string, string>): string {
|
||||
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
|
||||
return [
|
||||
"Paperclip API access note:",
|
||||
"Use shell commands with curl to make Paperclip API requests when needed.",
|
||||
"Include X-Paperclip-Run-Id on mutating requests.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type StageCleanup = {
|
||||
kind: "file" | "dir";
|
||||
path: string;
|
||||
};
|
||||
|
||||
type StagedGrokAssets = {
|
||||
cleanup: () => Promise<void>;
|
||||
stagedSkillsCount: number;
|
||||
stagedInstructionsPath: string | null;
|
||||
rulesFilePath: string | null;
|
||||
};
|
||||
|
||||
async function pathExists(candidate: string): Promise<boolean> {
|
||||
return fs.access(candidate).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
async function stageGrokProjectAssets(input: {
|
||||
cwd: string;
|
||||
instructionsFilePath: string;
|
||||
skillEntries: Array<{ key: string; runtimeName: string; source: string }>;
|
||||
desiredSkillNames: string[];
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<StagedGrokAssets> {
|
||||
const cleanup: StageCleanup[] = [];
|
||||
const ensureCleanupDir = (candidate: string) => {
|
||||
cleanup.push({ kind: "dir", path: candidate });
|
||||
};
|
||||
const ensureCleanupFile = (candidate: string) => {
|
||||
cleanup.push({ kind: "file", path: candidate });
|
||||
};
|
||||
|
||||
let stagedInstructionsPath: string | null = null;
|
||||
let rulesFilePath: string | null = null;
|
||||
let stagedSkillsCount = 0;
|
||||
|
||||
const instructionsTarget = path.join(input.cwd, "Agents.md");
|
||||
if (input.instructionsFilePath) {
|
||||
if (!await pathExists(instructionsTarget)) {
|
||||
await fs.copyFile(input.instructionsFilePath, instructionsTarget);
|
||||
ensureCleanupFile(instructionsTarget);
|
||||
stagedInstructionsPath = instructionsTarget;
|
||||
} else if (path.resolve(instructionsTarget) !== path.resolve(input.instructionsFilePath)) {
|
||||
rulesFilePath = input.instructionsFilePath;
|
||||
await input.onLog(
|
||||
"stdout",
|
||||
`[paperclip] Grok workspace already contains ${instructionsTarget}; using --rules @${input.instructionsFilePath} instead of overwriting it.\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const canonicalAgents = path.join(input.cwd, "AGENTS.md");
|
||||
if (!await pathExists(instructionsTarget) && await pathExists(canonicalAgents)) {
|
||||
await fs.copyFile(canonicalAgents, instructionsTarget);
|
||||
ensureCleanupFile(instructionsTarget);
|
||||
stagedInstructionsPath = instructionsTarget;
|
||||
}
|
||||
}
|
||||
|
||||
const desiredSet = new Set(input.desiredSkillNames);
|
||||
const selectedSkills = input.skillEntries.filter((entry) => desiredSet.has(entry.key));
|
||||
if (selectedSkills.length > 0) {
|
||||
const claudeDir = path.join(input.cwd, ".claude");
|
||||
const skillsRoot = path.join(claudeDir, "skills");
|
||||
if (!await pathExists(claudeDir)) {
|
||||
await fs.mkdir(claudeDir, { recursive: true });
|
||||
ensureCleanupDir(claudeDir);
|
||||
}
|
||||
if (!await pathExists(skillsRoot)) {
|
||||
await fs.mkdir(skillsRoot, { recursive: true });
|
||||
ensureCleanupDir(skillsRoot);
|
||||
}
|
||||
|
||||
for (const skill of selectedSkills) {
|
||||
const target = path.join(skillsRoot, skill.runtimeName);
|
||||
if (await pathExists(target)) {
|
||||
await input.onLog(
|
||||
"stdout",
|
||||
`[paperclip] Grok skill target already exists at ${target}; leaving it unchanged.\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await materializePaperclipSkillCopy(skill.source, target);
|
||||
ensureCleanupDir(target);
|
||||
stagedSkillsCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stagedSkillsCount,
|
||||
stagedInstructionsPath,
|
||||
rulesFilePath,
|
||||
cleanup: async () => {
|
||||
for (const entry of [...cleanup].reverse()) {
|
||||
if (entry.kind === "file") {
|
||||
await fs.rm(entry.path, { force: true }).catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
await fs.rm(entry.path, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "XAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||
const executionTarget = readAdapterExecutionTarget({
|
||||
executionTarget: ctx.executionTarget,
|
||||
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
|
||||
});
|
||||
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "grok");
|
||||
const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
|
||||
const permissionMode = asString(config.permissionMode, "dontAsk").trim() || "dontAsk";
|
||||
const reasoningEffort = asString(config.reasoningEffort, "").trim();
|
||||
const maxTurns = asNumber(config.maxTurns, 0);
|
||||
const alwaysApprove = asBoolean(config.alwaysApprove, true);
|
||||
const disableWebSearch = asBoolean(config.disableWebSearch, true);
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries);
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const stagedAssets = await stageGrokProjectAssets({
|
||||
cwd,
|
||||
instructionsFilePath,
|
||||
skillEntries: grokSkillEntries,
|
||||
desiredSkillNames: desiredGrokSkillNames,
|
||||
onLog,
|
||||
});
|
||||
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
|
||||
|
||||
try {
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceHints,
|
||||
agentHome,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
|
||||
executionTarget,
|
||||
asNumber(config.timeoutSec, 0),
|
||||
);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
if (executionTargetIsRemote) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||
);
|
||||
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
adapterKey: "grok",
|
||||
workspaceLocalDir: cwd,
|
||||
timeoutSec,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command,
|
||||
});
|
||||
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
|
||||
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceHints,
|
||||
agentHome,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
}
|
||||
|
||||
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
|
||||
timeoutSec,
|
||||
});
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
const billingType = resolveBillingType(effectiveEnv);
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Grok session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
||||
);
|
||||
} else if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Grok session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = ["Prompt is passed to Grok via --single in headless mode."];
|
||||
if (alwaysApprove) notes.push("Added --always-approve for unattended execution.");
|
||||
if (stagedAssets.stagedInstructionsPath) {
|
||||
notes.push(`Staged project instructions at ${stagedAssets.stagedInstructionsPath} for native Grok discovery.`);
|
||||
}
|
||||
if (stagedAssets.rulesFilePath) {
|
||||
notes.push(`Applied fallback instructions via --rules @${stagedAssets.rulesFilePath}.`);
|
||||
}
|
||||
if (stagedAssets.stagedSkillsCount > 0) {
|
||||
notes.push(`Staged ${stagedAssets.stagedSkillsCount} Paperclip skill(s) into .claude/skills for native Grok discovery.`);
|
||||
}
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model);
|
||||
if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (permissionMode) args.push("--permission-mode", permissionMode);
|
||||
if (alwaysApprove) args.push("--always-approve");
|
||||
if (disableWebSearch) args.push("--disable-web-search");
|
||||
if (stagedAssets.rulesFilePath) args.push("--rules", `@${stagedAssets.rulesFilePath}`);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("--single", prompt);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "grok_local",
|
||||
command: resolvedCommand,
|
||||
cwd: effectiveExecutionCwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, index) => (
|
||||
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||
)),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
proc,
|
||||
parsed: parseGrokJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseGrokJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
isRetry = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const failed = (attempt.proc.exitCode ?? 0) !== 0;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Grok exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
const canFallbackToRuntimeSession = !isRetry;
|
||||
const resolvedSessionId = attempt.parsed.sessionId
|
||||
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd: effectiveExecutionCwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
...(executionTargetIsRemote
|
||||
? {
|
||||
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
||||
}
|
||||
: {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: failed ? fallbackErrorMessage : null,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
},
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "xai",
|
||||
biller: billingType === "api" ? "xai" : "grok",
|
||||
model,
|
||||
billingType,
|
||||
costUsd: null,
|
||||
resultJson: {
|
||||
stopReason: attempt.parsed.stopReason,
|
||||
requestId: attempt.parsed.requestId,
|
||||
...(failed ? { stderr: attempt.proc.stderr } : {}),
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isGrokUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Grok resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
restoreRemoteWorkspace?.(),
|
||||
stagedAssets.cleanup(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { listGrokSkills, syncGrokSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
|
||||
|
||||
describe("parseGrokJsonl", () => {
|
||||
it("collects streamed thought/text content and final session metadata", () => {
|
||||
const parsed = parseGrokJsonl([
|
||||
JSON.stringify({ type: "thought", data: "Plan" }),
|
||||
JSON.stringify({ type: "thought", data: " first." }),
|
||||
JSON.stringify({ type: "text", data: "hel" }),
|
||||
JSON.stringify({ type: "text", data: "lo" }),
|
||||
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
|
||||
].join("\n"));
|
||||
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "sess-1",
|
||||
summary: "hello",
|
||||
thought: "Plan first.",
|
||||
errorMessage: null,
|
||||
stopReason: "EndTurn",
|
||||
requestId: "req-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads structured error payloads", () => {
|
||||
const parsed = parseGrokJsonl([
|
||||
JSON.stringify({ type: "error", error: { message: "Authentication required" } }),
|
||||
].join("\n"));
|
||||
|
||||
expect(parsed.errorMessage).toBe("Authentication required");
|
||||
});
|
||||
|
||||
it("separates reasoning turns that grok streaming-json glues together", () => {
|
||||
// PAPA-349: at turn boundaries grok drops the newline between turns; the
|
||||
// aggregated thought should still read as two paragraphs.
|
||||
const parsed = parseGrokJsonl([
|
||||
JSON.stringify({ type: "thought", data: "The user uses `" }),
|
||||
JSON.stringify({ type: "thought", data: "ls" }),
|
||||
JSON.stringify({ type: "thought", data: "`" }),
|
||||
JSON.stringify({ type: "thought", data: "The" }),
|
||||
JSON.stringify({ type: "thought", data: " `" }),
|
||||
JSON.stringify({ type: "thought", data: "ls" }),
|
||||
JSON.stringify({ type: "thought", data: "`" }),
|
||||
JSON.stringify({ type: "thought", data: " returned" }),
|
||||
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }),
|
||||
].join("\n"));
|
||||
|
||||
expect(parsed.thought).toBe("The user uses `ls`\nThe `ls` returned");
|
||||
});
|
||||
|
||||
it("preserves assistant `text` chunks verbatim (no boundary heuristic)", () => {
|
||||
// PAPA-349 review feedback: the turn-boundary helper is scoped to the
|
||||
// reasoning stream only. Final assistant text is stored unmodified so
|
||||
// user-visible responses cannot be reshaped by the heuristic.
|
||||
const parsed = parseGrokJsonl([
|
||||
JSON.stringify({ type: "text", data: "Done." }),
|
||||
JSON.stringify({ type: "text", data: "Next" }),
|
||||
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }),
|
||||
].join("\n"));
|
||||
|
||||
expect(parsed.summary).toBe("Done.Next");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGrokUnknownSessionError", () => {
|
||||
it("detects stale resume failures", () => {
|
||||
expect(isGrokUnknownSessionError("", "session not found")).toBe(true);
|
||||
expect(isGrokUnknownSessionError("", "everything fine")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { applyTurnBoundary, createTurnBoundaryState } from "../shared/turn-boundary.js";
|
||||
|
||||
export interface ParsedGrokJsonl {
|
||||
sessionId: string | null;
|
||||
summary: string;
|
||||
thought: string;
|
||||
errorMessage: string | null;
|
||||
stopReason: string | null;
|
||||
requestId: string | null;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message =
|
||||
asString(rec.message, "").trim() ||
|
||||
asString(rec.error, "").trim() ||
|
||||
asString(rec.detail, "").trim() ||
|
||||
asString(rec.code, "").trim();
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGrokJsonl(stdout: string): ParsedGrokJsonl {
|
||||
let sessionId: string | null = null;
|
||||
let stopReason: string | null = null;
|
||||
let requestId: string | null = null;
|
||||
let errorMessage: string | null = null;
|
||||
const thoughtParts: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
const thoughtBoundary = createTurnBoundaryState();
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const type = asString(event.type, "").trim();
|
||||
if (type === "thought") {
|
||||
const text = asString(event.data, "");
|
||||
if (text) thoughtParts.push(applyTurnBoundary(thoughtBoundary, text));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const text = asString(event.data, "");
|
||||
if (text) textParts.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "end") {
|
||||
sessionId = asString(event.sessionId, "").trim() || sessionId;
|
||||
stopReason = asString(event.stopReason, "").trim() || stopReason;
|
||||
requestId = asString(event.requestId, "").trim() || requestId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(event.error ?? event.message ?? event.detail ?? event.data).trim();
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: textParts.join("").trim(),
|
||||
thought: thoughtParts.join("").trim(),
|
||||
errorMessage,
|
||||
stopReason,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGrokUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|invalid\s+session/i.test(haystack);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildRuntimeMountedSkillSnapshot,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function buildGrokSkillSnapshot(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "grok_local",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
configuredDetail: "Will be copied into `.claude/skills` in the execution workspace on the next run.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listGrokSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildGrokSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncGrokSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
return buildGrokSkillSnapshot(ctx.config);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const runProcessMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
|
||||
describeAdapterExecutionTarget: () => "local",
|
||||
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
|
||||
ensureAdapterExecutionTargetDirectory: ensureDirectoryMock,
|
||||
resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) =>
|
||||
configuredCwd || fallbackCwd,
|
||||
runAdapterExecutionTargetProcess: runProcessMock,
|
||||
}));
|
||||
|
||||
import { parseGrokModelsOutput, testEnvironment } from "./test.js";
|
||||
|
||||
describe("parseGrokModelsOutput", () => {
|
||||
it("extracts auth state and models from `grok models` output", () => {
|
||||
expect(parseGrokModelsOutput([
|
||||
"You are logged in with grok.com.",
|
||||
"",
|
||||
"Default model: grok-build",
|
||||
"",
|
||||
"Available models:",
|
||||
" * grok-build (default)",
|
||||
" * grok-code",
|
||||
].join("\n"))).toEqual({
|
||||
authenticated: true,
|
||||
defaultModel: "grok-build",
|
||||
models: ["grok-build", "grok-code"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("grok_local testEnvironment", () => {
|
||||
beforeEach(() => {
|
||||
ensureDirectoryMock.mockClear();
|
||||
ensureCommandMock.mockClear();
|
||||
runProcessMock.mockReset();
|
||||
});
|
||||
|
||||
it("reports a healthy authenticated host with a working hello probe", async () => {
|
||||
runProcessMock
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
"You are logged in with grok.com.",
|
||||
"",
|
||||
"Default model: grok-build",
|
||||
"",
|
||||
"Available models:",
|
||||
" * grok-build (default)",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "text", data: "hello" }),
|
||||
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "grok_local",
|
||||
config: {
|
||||
command: "grok",
|
||||
cwd: "/tmp/project",
|
||||
model: "grok-build",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"grok_command_resolvable",
|
||||
"grok_models_probe_passed",
|
||||
"grok_model_configured",
|
||||
"grok_hello_probe_passed",
|
||||
]),
|
||||
);
|
||||
expect(runProcessMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(String),
|
||||
null,
|
||||
"grok",
|
||||
expect.arrayContaining([
|
||||
"--output-format",
|
||||
"streaming-json",
|
||||
"--always-approve",
|
||||
"--permission-mode",
|
||||
"dontAsk",
|
||||
"--disable-web-search",
|
||||
"--single",
|
||||
"Respond with exactly hello.",
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("downgrades auth failures to warnings", async () => {
|
||||
runProcessMock
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Not logged in. Run `grok login`.",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Not logged in. Run `grok login`.",
|
||||
});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "grok_local",
|
||||
config: {
|
||||
command: "grok",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"grok_auth_required",
|
||||
"grok_hello_probe_auth_required",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
ensurePathInEnv,
|
||||
parseObject,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
runAdapterExecutionTargetProcess,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||
import { parseGrokJsonl } from "./parse.js";
|
||||
|
||||
export interface GrokModelsProbe {
|
||||
authenticated: boolean;
|
||||
defaultModel: string | null;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean;
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const GROK_AUTH_REQUIRED_RE =
|
||||
/(?:not\s+logged\s+in|login\s+required|run\s+`?grok\s+login`?|authentication\s+required|unauthorized|invalid\s+credentials)/i;
|
||||
|
||||
export function parseGrokModelsOutput(stdout: string): GrokModelsProbe {
|
||||
const trimmedLines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const models: string[] = [];
|
||||
let defaultModel: string | null = null;
|
||||
let authenticated = false;
|
||||
let inModelsBlock = false;
|
||||
|
||||
for (const line of trimmedLines) {
|
||||
if (/logged in/i.test(line)) authenticated = true;
|
||||
const defaultMatch = /^Default model:\s*(.+)$/i.exec(line);
|
||||
if (defaultMatch?.[1]) {
|
||||
defaultModel = defaultMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
if (/^Available models:/i.test(line)) {
|
||||
inModelsBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (!inModelsBlock) continue;
|
||||
const bulletMatch = /^[*-]\s*(.+?)(?:\s+\(default\))?$/.exec(line);
|
||||
if (bulletMatch?.[1]) {
|
||||
models.push(bulletMatch[1].trim());
|
||||
continue;
|
||||
}
|
||||
if (line.length > 0) {
|
||||
models.push(line.replace(/\s+\(default\)$/, "").trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated,
|
||||
defaultModel,
|
||||
models: Array.from(new Set(models.filter(Boolean))),
|
||||
};
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "grok");
|
||||
const target = ctx.executionTarget ?? null;
|
||||
const targetIsRemote = target?.kind === "remote";
|
||||
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
|
||||
const targetLabel = targetIsRemote
|
||||
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
|
||||
: null;
|
||||
const runId = `grok-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
if (targetLabel) {
|
||||
checks.push({
|
||||
code: "grok_environment_target",
|
||||
level: "info",
|
||||
message: `Probing inside environment: ${targetLabel}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||
cwd,
|
||||
env: {},
|
||||
createIfMissing: true,
|
||||
});
|
||||
checks.push({
|
||||
code: "grok_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "grok_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const env = normalizeEnv(config.env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "grok_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "grok_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "grok_cwd_invalid" && check.code !== "grok_command_unresolvable");
|
||||
|
||||
const configuredModel = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
|
||||
|
||||
if (canRunProbe) {
|
||||
const modelsProbe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
["models"],
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const probeOutput = `${modelsProbe.stdout}\n${modelsProbe.stderr}`;
|
||||
const parsedModels = parseGrokModelsOutput(modelsProbe.stdout);
|
||||
const authRequired = GROK_AUTH_REQUIRED_RE.test(probeOutput);
|
||||
|
||||
if (modelsProbe.timedOut) {
|
||||
checks.push({
|
||||
code: "grok_models_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "`grok models` timed out.",
|
||||
hint: "Retry the probe. If this persists, run `grok models` manually from the target environment.",
|
||||
});
|
||||
} else if ((modelsProbe.exitCode ?? 1) !== 0) {
|
||||
checks.push({
|
||||
code: authRequired ? "grok_auth_required" : "grok_models_probe_failed",
|
||||
level: authRequired ? "warn" : "error",
|
||||
message: authRequired
|
||||
? "Grok CLI is not authenticated."
|
||||
: "`grok models` failed.",
|
||||
detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null),
|
||||
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "grok_models_probe_passed",
|
||||
level: "info",
|
||||
message: parsedModels.authenticated
|
||||
? "Grok CLI authentication is configured."
|
||||
: "`grok models` completed.",
|
||||
detail: parsedModels.defaultModel ? `Default model: ${parsedModels.defaultModel}` : undefined,
|
||||
});
|
||||
if (parsedModels.models.length > 0) {
|
||||
checks.push({
|
||||
code: "grok_models_discovered",
|
||||
level: "info",
|
||||
message: `Discovered ${parsedModels.models.length} Grok model(s).`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "grok_models_empty",
|
||||
level: "warn",
|
||||
message: "Grok returned no available models.",
|
||||
hint: "Run `grok models` manually and verify the account has access to a model.",
|
||||
});
|
||||
}
|
||||
if (configuredModel) {
|
||||
checks.push({
|
||||
code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found",
|
||||
level: parsedModels.models.includes(configuredModel) ? "info" : "warn",
|
||||
message: parsedModels.models.includes(configuredModel)
|
||||
? `Configured model: ${configuredModel}`
|
||||
: `Configured model "${configuredModel}" not found in available models.`,
|
||||
hint: parsedModels.models.includes(configuredModel)
|
||||
? undefined
|
||||
: "Run `grok models` and choose an available model id.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canRunProbe) {
|
||||
const probeArgs = [
|
||||
"--output-format",
|
||||
"streaming-json",
|
||||
"--always-approve",
|
||||
"--permission-mode",
|
||||
"dontAsk",
|
||||
"--disable-web-search",
|
||||
];
|
||||
if (configuredModel && configuredModel !== DEFAULT_GROK_LOCAL_MODEL) {
|
||||
probeArgs.push("--model", configuredModel);
|
||||
}
|
||||
probeArgs.push("--single", "Respond with exactly hello.");
|
||||
|
||||
const helloProbe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
probeArgs,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
const parsed = parseGrokJsonl(helloProbe.stdout);
|
||||
const detail = summarizeProbeDetail(helloProbe.stdout, helloProbe.stderr, parsed.errorMessage);
|
||||
const authRequired = GROK_AUTH_REQUIRED_RE.test(`${helloProbe.stdout}\n${helloProbe.stderr}`);
|
||||
|
||||
if (helloProbe.timedOut) {
|
||||
checks.push({
|
||||
code: "grok_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Grok hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Grok can run a simple `--single` prompt manually.",
|
||||
});
|
||||
} else if ((helloProbe.exitCode ?? 1) !== 0) {
|
||||
checks.push({
|
||||
code: authRequired ? "grok_hello_probe_auth_required" : "grok_hello_probe_failed",
|
||||
level: authRequired ? "warn" : "error",
|
||||
message: authRequired
|
||||
? "Grok CLI could not answer the hello probe because authentication is missing."
|
||||
: "Grok hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
|
||||
});
|
||||
} else if (/\bhello\b/i.test(parsed.summary)) {
|
||||
checks.push({
|
||||
code: "grok_hello_probe_passed",
|
||||
level: "info",
|
||||
message: "Grok hello probe succeeded.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "grok_hello_probe_unexpected_output",
|
||||
level: "warn",
|
||||
message: "Grok hello probe succeeded but returned unexpected output.",
|
||||
...(detail ? { detail } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: "grok_local",
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyTurnBoundary, createTurnBoundaryState } from "./turn-boundary.js";
|
||||
|
||||
function run(chunks: string[]): string {
|
||||
const state = createTurnBoundaryState();
|
||||
return chunks.map((chunk) => applyTurnBoundary(state, chunk)).join("");
|
||||
}
|
||||
|
||||
describe("applyTurnBoundary", () => {
|
||||
it("inserts a newline when a closing backtick is followed by a new capitalized turn", () => {
|
||||
expect(run(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
|
||||
.toBe("The user uses `ls`\nThe `ls` returned");
|
||||
});
|
||||
|
||||
it("inserts a newline after sentence-ending punctuation glued to a capitalized word", () => {
|
||||
expect(run(["returned", ":", "Confirmed", ":", " 4 files"]))
|
||||
.toBe("returned:\nConfirmed: 4 files");
|
||||
});
|
||||
|
||||
it("does not break apart backtick-wrapped CamelCase identifiers within a turn", () => {
|
||||
expect(run(["render `", "React", "` then "]))
|
||||
.toBe("render `React` then ");
|
||||
});
|
||||
|
||||
it("leaves natural token streams with proper whitespace alone", () => {
|
||||
expect(run(["The", " user", " wants", " me", " to", ":\n", "1", ".", " List"]))
|
||||
.toBe("The user wants me to:\n1. List");
|
||||
});
|
||||
|
||||
it("does not insert a separator when the next chunk starts with whitespace", () => {
|
||||
expect(run(["function", ".", " They"]))
|
||||
.toBe("function. They");
|
||||
});
|
||||
|
||||
it("does not insert a separator when the next chunk starts lowercase", () => {
|
||||
expect(run(["`", "ls", "`"]))
|
||||
.toBe("`ls`");
|
||||
});
|
||||
|
||||
it("does not insert a separator when the next chunk is a single character", () => {
|
||||
expect(run([":", "A"]))
|
||||
.toBe(":A");
|
||||
});
|
||||
|
||||
it("does not insert a separator after a self-contained backtick span in a single chunk", () => {
|
||||
// Greptile review: a chunk like "`ls`" is a balanced span; the following
|
||||
// capitalized word should be treated as a continuation, not a new turn.
|
||||
expect(run(["`ls`", "Then"]))
|
||||
.toBe("`ls`Then");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Grok's `--output-format streaming-json` mode emits `thought` and `text` events
|
||||
// token-by-token. Between reasoning turns (around tool calls) it drops the `\n`
|
||||
// separator that the non-streaming `--output-format json` mode includes in the
|
||||
// aggregated `thought` field. This helper inserts a single `\n` when a new chunk
|
||||
// would otherwise glue two turns together (e.g. ``"`"`` then `"The"` => `` `The``).
|
||||
|
||||
export interface TurnBoundaryState {
|
||||
lastChunk: string;
|
||||
backtickParity: 0 | 1;
|
||||
}
|
||||
|
||||
export function createTurnBoundaryState(): TurnBoundaryState {
|
||||
return { lastChunk: "", backtickParity: 0 };
|
||||
}
|
||||
|
||||
function countBackticks(text: string): number {
|
||||
let count = 0;
|
||||
for (const ch of text) if (ch === "`") count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
function endsWithSentenceClose(ch: string): boolean {
|
||||
return ch === "." || ch === "?" || ch === "!" || ch === ":" || ch === ";";
|
||||
}
|
||||
|
||||
export function applyTurnBoundary(state: TurnBoundaryState, incoming: string): string {
|
||||
if (!incoming) return incoming;
|
||||
|
||||
let output = incoming;
|
||||
const prev = state.lastChunk;
|
||||
if (
|
||||
prev &&
|
||||
!/\s$/.test(prev) &&
|
||||
!/^\s/.test(incoming) &&
|
||||
/^[A-Z]/.test(incoming) &&
|
||||
incoming.length >= 2
|
||||
) {
|
||||
const lastChar = prev[prev.length - 1]!;
|
||||
// Narrow the backtick trigger to a lone closing-backtick chunk (e.g. the
|
||||
// stream "...`", "ls", "`" then "The"). A compound chunk like "`ls`" is a
|
||||
// self-contained span and the following capitalized word is a continuation,
|
||||
// not a new turn.
|
||||
const closingLoneBacktick =
|
||||
prev === "`" && state.backtickParity === 0;
|
||||
const looksLikeNewTurn = endsWithSentenceClose(lastChar) || closingLoneBacktick;
|
||||
if (looksLikeNewTurn) {
|
||||
output = `\n${incoming}`;
|
||||
}
|
||||
}
|
||||
|
||||
state.lastChunk = incoming;
|
||||
state.backtickParity = ((state.backtickParity + countBackticks(incoming)) % 2) as 0 | 1;
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGrokLocalConfig } from "./build-config.js";
|
||||
|
||||
describe("buildGrokLocalConfig", () => {
|
||||
it("maps create-form values into adapter config", () => {
|
||||
expect(buildGrokLocalConfig({
|
||||
cwd: "/tmp/project",
|
||||
instructionsFilePath: "/tmp/AGENTS.md",
|
||||
model: "grok-build",
|
||||
thinkingEffort: "high",
|
||||
envVars: "XAI_API_KEY=secret\n",
|
||||
extraArgs: "--check, --verbatim",
|
||||
} as never)).toEqual({
|
||||
cwd: "/tmp/project",
|
||||
instructionsFilePath: "/tmp/AGENTS.md",
|
||||
model: "grok-build",
|
||||
timeoutSec: 0,
|
||||
graceSec: 20,
|
||||
reasoningEffort: "high",
|
||||
env: {
|
||||
XAI_API_KEY: { type: "plain", value: "secret" },
|
||||
},
|
||||
extraArgs: ["--check", "--verbatim"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildGrokLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
ac.model = v.model || DEFAULT_GROK_LOCAL_MODEL;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 20;
|
||||
if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseGrokStdoutLine, createGrokStdoutParser } from "./parse-stdout.js";
|
||||
export { buildGrokLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createGrokStdoutParser, parseGrokStdoutLine } from "./parse-stdout.js";
|
||||
|
||||
describe("parseGrokStdoutLine", () => {
|
||||
const ts = "2026-05-15T00:00:00.000Z";
|
||||
|
||||
it("maps thought/text/end events into transcript entries", () => {
|
||||
expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([
|
||||
{ kind: "thinking", ts, text: "Plan first.", delta: true },
|
||||
]);
|
||||
expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([
|
||||
{ kind: "assistant", ts, text: "hello", delta: true },
|
||||
]);
|
||||
expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([
|
||||
{ kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces structured Grok error payload text", () => {
|
||||
expect(parseGrokStdoutLine(JSON.stringify({
|
||||
type: "error",
|
||||
error: { message: "Authentication required" },
|
||||
}), ts)).toEqual([
|
||||
{ kind: "stderr", ts, text: "Authentication required" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGrokStdoutParser", () => {
|
||||
const ts = "2026-05-15T00:00:00.000Z";
|
||||
|
||||
function thoughtTexts(chunks: string[]): string {
|
||||
const parser = createGrokStdoutParser();
|
||||
return chunks
|
||||
.map((data) => parser.parseLine(JSON.stringify({ type: "thought", data }), ts))
|
||||
.flat()
|
||||
.map((entry) => entry.kind === "thinking" ? entry.text : "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
it("inserts a newline between reasoning turns that grok streaming-json glues together", () => {
|
||||
// Reproduces PAPA-349: token stream "...using `ls`" then a new turn "The `ls` command returned"
|
||||
expect(thoughtTexts(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
|
||||
.toBe("The user uses `ls`\nThe `ls` returned");
|
||||
});
|
||||
|
||||
it("inserts a newline when a turn ends with a colon and the next turn starts capitalized", () => {
|
||||
expect(thoughtTexts(["returned", ":", "Confirmed", ":", " 4 files"]))
|
||||
.toBe("returned:\nConfirmed: 4 files");
|
||||
});
|
||||
|
||||
it("resets state between independent transcript builds", () => {
|
||||
const parser = createGrokStdoutParser();
|
||||
parser.parseLine(JSON.stringify({ type: "thought", data: "first:" }), ts);
|
||||
parser.reset();
|
||||
expect(parser.parseLine(JSON.stringify({ type: "thought", data: "Second" }), ts)).toEqual([
|
||||
{ kind: "thinking", ts, text: "Second", delta: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not modify assistant `text` chunks", () => {
|
||||
// PAPA-349 review feedback: keep final assistant text streaming verbatim;
|
||||
// the boundary heuristic is scoped to reasoning.
|
||||
const parser = createGrokStdoutParser();
|
||||
parser.parseLine(JSON.stringify({ type: "text", data: "Done." }), ts);
|
||||
expect(parser.parseLine(JSON.stringify({ type: "text", data: "Next" }), ts)).toEqual([
|
||||
{ kind: "assistant", ts, text: "Next", delta: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import { applyTurnBoundary, createTurnBoundaryState, type TurnBoundaryState } from "../shared/turn-boundary.js";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function extractErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
return asString(record.message) || asString(record.detail) || asString(record.code);
|
||||
}
|
||||
|
||||
function parseLineInternal(
|
||||
line: string,
|
||||
ts: string,
|
||||
thoughtBoundary: TurnBoundaryState,
|
||||
): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type).trim();
|
||||
|
||||
if (type === "thought") {
|
||||
const text = asString(parsed.data);
|
||||
if (!text) return [];
|
||||
return [{ kind: "thinking", ts, text: applyTurnBoundary(thoughtBoundary, text), delta: true }];
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const text = asString(parsed.data);
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text, delta: true }];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = asString(parsed.data) || asString(parsed.message) || extractErrorText(parsed.error);
|
||||
return text ? [{ kind: "stderr", ts, text }] : [{ kind: "stderr", ts, text: "Grok error" }];
|
||||
}
|
||||
|
||||
if (type === "end") {
|
||||
const stopReason = asString(parsed.stopReason).trim();
|
||||
const sessionId = asString(parsed.sessionId).trim();
|
||||
const parts = [
|
||||
stopReason ? `stop_reason=${stopReason}` : "",
|
||||
sessionId ? `session=${sessionId}` : "",
|
||||
].filter(Boolean);
|
||||
return [{ kind: "system", ts, text: parts.join(" ") || "run completed" }];
|
||||
}
|
||||
|
||||
return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }];
|
||||
}
|
||||
|
||||
export function createGrokStdoutParser() {
|
||||
let thoughtBoundary = createTurnBoundaryState();
|
||||
return {
|
||||
parseLine(line: string, ts: string): TranscriptEntry[] {
|
||||
return parseLineInternal(line, ts, thoughtBoundary);
|
||||
},
|
||||
reset() {
|
||||
thoughtBoundary = createTurnBoundaryState();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Stateless fallback for callers that haven't migrated to the stateful factory.
|
||||
// Without state, consecutive thought chunks at reasoning-turn boundaries can
|
||||
// still appear merged; prefer createGrokStdoutParser for live transcripts.
|
||||
export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
return parseLineInternal(line, ts, createTurnBoundaryState());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -70,3 +70,16 @@ Structured gateway event logs use:
|
||||
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
|
||||
|
||||
UI/CLI parsers consume these lines to render transcript updates.
|
||||
|
||||
## No-remote-git contract
|
||||
|
||||
Like every Paperclip adapter, this one must treat the local execution-workspace
|
||||
cwd as the only persistence boundary across runs — no `git push` from runtime
|
||||
code, no assuming a `git remote` exists. The gateway transport here doesn't
|
||||
touch the workspace directly, but if you extend the adapter to ship code to
|
||||
the OpenClaw side, use the round-trip helpers in `@paperclipai/adapter-utils`
|
||||
(`prepareWorkspaceForSshExecution` → `restoreWorkspaceFromSshExecution`)
|
||||
rather than reaching for a git remote. See
|
||||
[`packages/adapters/AUTHORING.md`](../AUTHORING.md#no-remote-git-contract-cross-run-persistence)
|
||||
for the full contract and the pinning test at
|
||||
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../../adapter-utils/src/ssh-fixture.test.ts).
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
@@ -72,6 +73,7 @@ export async function testEnvironment(
|
||||
const command = asString(config.command, "opencode");
|
||||
const target = ctx.executionTarget ?? null;
|
||||
const targetIsRemote = target?.kind === "remote";
|
||||
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
|
||||
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
|
||||
const targetLabel = targetIsRemote
|
||||
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
|
||||
@@ -334,6 +336,14 @@ export async function testEnvironment(
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
// Sandbox bridges still add cold-start and transport overhead, but the
|
||||
// standard-2 Cloudflare tier now probes quickly enough that 90s keeps
|
||||
// useful headroom without letting slow hangs linger.
|
||||
const helloProbeTimeoutSec = Math.max(
|
||||
1,
|
||||
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 60),
|
||||
);
|
||||
|
||||
try {
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
@@ -343,7 +353,7 @@ export async function testEnvironment(
|
||||
{
|
||||
cwd: runtimeCwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 60,
|
||||
timeoutSec: helloProbeTimeoutSec,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
export const type = "pi_local";
|
||||
export const label = "Pi (local)";
|
||||
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent";
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent@0.74.0";
|
||||
|
||||
export const models: Array<{ id: string; label: string }> = [];
|
||||
|
||||
|
||||
@@ -309,6 +309,107 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
||||
60_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"preserves composite foreign key column order without duplicate referenced columns",
|
||||
async () => {
|
||||
const sourceConnectionString = await createTempDatabase();
|
||||
const restoreConnectionString = await createSiblingDatabase(
|
||||
sourceConnectionString,
|
||||
"paperclip_composite_fk_restore_target",
|
||||
);
|
||||
const backupDir = createTempDir("paperclip-db-composite-fk-backup-");
|
||||
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
|
||||
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
||||
|
||||
try {
|
||||
await sourceSql.unsafe(`
|
||||
CREATE SCHEMA "plugin_composite_fk";
|
||||
CREATE TABLE "plugin_composite_fk"."content_cases" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"company_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
CONSTRAINT "content_cases_company_case_unique" UNIQUE ("company_id", "id")
|
||||
);
|
||||
CREATE TABLE "plugin_composite_fk"."content_case_signals" (
|
||||
"company_id" uuid NOT NULL,
|
||||
"case_id" uuid NOT NULL,
|
||||
"signal" text NOT NULL,
|
||||
"scopes" text[] NOT NULL,
|
||||
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
CONSTRAINT "content_case_signals_company_case"
|
||||
FOREIGN KEY ("company_id", "case_id")
|
||||
REFERENCES "plugin_composite_fk"."content_cases" ("company_id", "id")
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO "plugin_composite_fk"."content_cases" ("company_id", "id", "title")
|
||||
VALUES (
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'case'
|
||||
);
|
||||
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes", "warnings")
|
||||
VALUES (
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'signal',
|
||||
ARRAY['upstream_import:preview', 'scope with space', 'quoted "scope"', 'NULL', 'null'],
|
||||
jsonb_build_array('json warning', jsonb_build_object('code', 'quoted "value"'))
|
||||
);
|
||||
`);
|
||||
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir,
|
||||
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
filenamePrefix: "paperclip-composite-fk-test",
|
||||
backupEngine: "javascript",
|
||||
});
|
||||
|
||||
await runDatabaseRestore({
|
||||
connectionString: restoreConnectionString,
|
||||
backupFile: result.backupFile,
|
||||
});
|
||||
|
||||
const rows = await restoreSql.unsafe<{
|
||||
signal: string;
|
||||
title: string;
|
||||
scopes: string[];
|
||||
warnings: Array<string | { code: string }>;
|
||||
}[]>(`
|
||||
SELECT s."signal", c."title", s."scopes", s."warnings"
|
||||
FROM "plugin_composite_fk"."content_case_signals" s
|
||||
JOIN "plugin_composite_fk"."content_cases" c
|
||||
ON c."company_id" = s."company_id"
|
||||
AND c."id" = s."case_id"
|
||||
`);
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
signal: "signal",
|
||||
title: "case",
|
||||
scopes: ["upstream_import:preview", "scope with space", 'quoted "scope"', "NULL", "null"],
|
||||
warnings: ["json warning", { code: 'quoted "value"' }],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
restoreSql.unsafe(`
|
||||
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes")
|
||||
VALUES (
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'33333333-3333-4333-8333-333333333333',
|
||||
'orphan',
|
||||
ARRAY[]::text[]
|
||||
)
|
||||
`),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await sourceSql.end();
|
||||
await restoreSql.end();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"restores legacy public-only backups without migration history",
|
||||
async () => {
|
||||
|
||||
@@ -249,12 +249,39 @@ function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean {
|
||||
Object.keys(opts.nullifyColumns ?? {}).length > 0;
|
||||
}
|
||||
|
||||
function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set<string>): string {
|
||||
function formatPostgresArrayElement(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (Array.isArray(value)) return formatPostgresArrayLiteral(value);
|
||||
const raw = value instanceof Date
|
||||
? value.toISOString()
|
||||
: typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
if (raw.length === 0 || /^null$/i.test(raw) || /[{}\s,"\\]/.test(raw)) {
|
||||
return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function formatPostgresArrayLiteral(value: unknown[]): string {
|
||||
return `{${value.map(formatPostgresArrayElement).join(",")}}`;
|
||||
}
|
||||
|
||||
function formatSqlValue(
|
||||
rawValue: unknown,
|
||||
columnName: string | undefined,
|
||||
nullifiedColumns: Set<string>,
|
||||
dataType?: string,
|
||||
): string {
|
||||
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
|
||||
if (val === null || val === undefined) return "NULL";
|
||||
if (dataType === "json" || dataType === "jsonb") {
|
||||
return formatSqlLiteral(JSON.stringify(val));
|
||||
}
|
||||
if (typeof val === "boolean") return val ? "true" : "false";
|
||||
if (typeof val === "number") return String(val);
|
||||
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
|
||||
if (Array.isArray(val)) return formatSqlLiteral(formatPostgresArrayLiteral(val));
|
||||
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
|
||||
return formatSqlLiteral(String(val));
|
||||
}
|
||||
@@ -745,58 +772,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Foreign keys (after all tables created)
|
||||
const allForeignKeys = await sql<{
|
||||
constraint_name: string;
|
||||
source_schema: string;
|
||||
source_table: string;
|
||||
source_columns: string[];
|
||||
target_schema: string;
|
||||
target_table: string;
|
||||
target_columns: string[];
|
||||
update_rule: string;
|
||||
delete_rule: string;
|
||||
}[]>`
|
||||
SELECT
|
||||
c.conname AS constraint_name,
|
||||
srcn.nspname AS source_schema,
|
||||
src.relname AS source_table,
|
||||
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
|
||||
tgtn.nspname AS target_schema,
|
||||
tgt.relname AS target_table,
|
||||
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
|
||||
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
|
||||
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class src ON src.oid = c.conrelid
|
||||
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
|
||||
JOIN pg_class tgt ON tgt.oid = c.confrelid
|
||||
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
|
||||
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
|
||||
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
|
||||
WHERE c.contype = 'f'
|
||||
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
|
||||
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
|
||||
ORDER BY srcn.nspname, src.relname, c.conname
|
||||
`;
|
||||
const fks = allForeignKeys.filter(
|
||||
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
|
||||
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
|
||||
);
|
||||
|
||||
if (fks.length > 0) {
|
||||
emit("-- Foreign keys");
|
||||
for (const fk of fks) {
|
||||
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
|
||||
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
|
||||
emitStatement(
|
||||
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Unique constraints
|
||||
// Unique constraints must exist before foreign keys that reference them.
|
||||
const allUniqueConstraints = await sql<{
|
||||
constraint_name: string;
|
||||
schema_name: string;
|
||||
@@ -827,6 +803,58 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Foreign keys (after all tables and referenced unique constraints are created)
|
||||
const allForeignKeys = await sql<{
|
||||
constraint_name: string;
|
||||
source_schema: string;
|
||||
source_table: string;
|
||||
source_columns: string[];
|
||||
target_schema: string;
|
||||
target_table: string;
|
||||
target_columns: string[];
|
||||
update_rule: string;
|
||||
delete_rule: string;
|
||||
}[]>`
|
||||
SELECT
|
||||
c.conname AS constraint_name,
|
||||
srcn.nspname AS source_schema,
|
||||
src.relname AS source_table,
|
||||
array_agg(sa.attname ORDER BY key_columns.ordinal_position) AS source_columns,
|
||||
tgtn.nspname AS target_schema,
|
||||
tgt.relname AS target_table,
|
||||
array_agg(ta.attname ORDER BY key_columns.ordinal_position) AS target_columns,
|
||||
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
|
||||
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class src ON src.oid = c.conrelid
|
||||
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
|
||||
JOIN pg_class tgt ON tgt.oid = c.confrelid
|
||||
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
|
||||
JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS key_columns(source_attnum, target_attnum, ordinal_position) ON true
|
||||
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = key_columns.source_attnum
|
||||
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = key_columns.target_attnum
|
||||
WHERE c.contype = 'f'
|
||||
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
|
||||
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
|
||||
ORDER BY srcn.nspname, src.relname, c.conname
|
||||
`;
|
||||
const fks = allForeignKeys.filter(
|
||||
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
|
||||
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
|
||||
);
|
||||
|
||||
if (fks.length > 0) {
|
||||
emit("-- Foreign keys");
|
||||
for (const fk of fks) {
|
||||
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
|
||||
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
|
||||
emitStatement(
|
||||
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Indexes (non-primary, non-unique-constraint)
|
||||
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
|
||||
SELECT schemaname AS schema_name, tablename, indexdef
|
||||
@@ -895,7 +923,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
for await (const rows of rowCursor) {
|
||||
for (const row of rows) {
|
||||
const values = row.map((rawValue, index) =>
|
||||
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns),
|
||||
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns, cols[index]?.data_type),
|
||||
);
|
||||
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
|
||||
}
|
||||
|
||||