Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82e40fb370 | |||
| 06d692e87d | |||
| cb15a87f64 | |||
| 6094081a71 | |||
| e739a2d130 | |||
| 3610559cc7 | |||
| 157729ea95 | |||
| 9615c90c4f | |||
| 2e522c9b37 | |||
| ba44667ae3 | |||
| 9f15436e05 | |||
| 3144df70d6 | |||
| 268806ff1c | |||
| a77fc31dc1 | |||
| aed1a886a9 | |||
| ddf36667f9 | |||
| e2af316b3e | |||
| 92afa0fb67 | |||
| 9c7a17e16c | |||
| 119bf6336e | |||
| 6b30178766 | |||
| 8b64cf10ef | |||
| f44ae212f6 | |||
| a1343ea4f5 | |||
| e454277d10 | |||
| 4c413429be | |||
| 2d05002d73 | |||
| 772130a573 | |||
| edc77da082 | |||
| ec4e94a6e7 | |||
| 0b224f0864 | |||
| 54c5e7cb41 | |||
| 022a0df61a | |||
| b672ebb540 | |||
| 4640417166 | |||
| ae0b344915 | |||
| 26155c2b90 | |||
| 99c3289d8e | |||
| 8dff385086 | |||
| 002c470ee7 | |||
| 21411b80b2 | |||
| 7d55b8d9d0 | |||
| b8133d6a35 | |||
| 9175a8ee85 | |||
| 1956ccd7b5 | |||
| e3c172a06f | |||
| 89909db27c | |||
| d9341795b0 | |||
| ef7e6be8bb | |||
| 3674cef645 | |||
| 296d051bd5 | |||
| 45892739a5 | |||
| 857e9e4f01 | |||
| fa03b5944e |
@@ -154,14 +154,6 @@ Each AGENTS.md body should include not just what the agent does, but how they fi
|
|||||||
|
|
||||||
This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them.
|
This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them.
|
||||||
|
|
||||||
Add a concise execution contract to every generated working agent:
|
|
||||||
|
|
||||||
- Start actionable work in the same heartbeat and do not stop at a plan unless planning was requested.
|
|
||||||
- Leave durable progress in comments, documents, or work products with the next action.
|
|
||||||
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
|
|
||||||
- Mark blocked work with the unblock owner and action.
|
|
||||||
- Respect budget, pause/cancel, approval gates, and company boundaries.
|
|
||||||
|
|
||||||
### Step 5: Confirm Output Location
|
### Step 5: Confirm Output Location
|
||||||
|
|
||||||
Ask the user where to write the package. Common options:
|
Ask the user where to write the package. Common options:
|
||||||
|
|||||||
@@ -105,13 +105,6 @@ Your responsibilities:
|
|||||||
- Implement features and fix bugs
|
- Implement features and fix bugs
|
||||||
- Write tests and documentation
|
- Write tests and documentation
|
||||||
- Participate in code reviews
|
- Participate in code reviews
|
||||||
|
|
||||||
Execution contract:
|
|
||||||
|
|
||||||
- Start actionable implementation work in the same heartbeat; do not stop at a plan unless planning was requested.
|
|
||||||
- Leave durable progress with a clear next action.
|
|
||||||
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
|
|
||||||
- Mark blocked work with the unblock owner and action.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## teams/engineering/TEAM.md
|
## teams/engineering/TEAM.md
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ Import from `@paperclipai/adapter-utils/server-utils`:
|
|||||||
### Prompt Templates
|
### Prompt Templates
|
||||||
- Support `promptTemplate` for every run
|
- Support `promptTemplate` for every run
|
||||||
- Use `renderTemplate()` with the standard variable set
|
- Use `renderTemplate()` with the standard variable set
|
||||||
- Default prompt should use `DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE` from `@paperclipai/adapter-utils/server-utils` so local adapters share Paperclip's execution contract: act in the same heartbeat, avoid planning-only exits unless requested, leave durable progress and a next action, use child issues instead of polling, mark blockers with owner/action, and respect governance boundaries.
|
- Default prompt: `"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work."`
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- Differentiate timeout vs process error vs parse failure
|
- Differentiate timeout vs process error vs parse failure
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
---
|
|
||||||
name: deal-with-security-advisory
|
|
||||||
description: >
|
|
||||||
Handle a GitHub Security Advisory response for Paperclip, including
|
|
||||||
confidential fix development in a temporary private fork, human coordination
|
|
||||||
on advisory-thread comments, CVE request, synchronized advisory publication,
|
|
||||||
and immediate security release steps.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Security Vulnerability Response Instructions
|
|
||||||
|
|
||||||
## ⚠️ CRITICAL: This is a security vulnerability. Everything about this process is confidential until the advisory is published. Do not mention the vulnerability details in any public commit message, PR title, branch name, or comment. Do not push anything to a public branch. Do not discuss specifics in any public channel. Assume anything on the public repo is visible to attackers who will exploit the window between disclosure and user upgrades.
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
A security vulnerability has been reported via GitHub Security Advisory:
|
|
||||||
|
|
||||||
* **Advisory:** {{ghsaId}} (e.g. GHSA-x8hx-rhr2-9rf7)
|
|
||||||
* **Reporter:** {{reporterHandle}}
|
|
||||||
* **Severity:** {{severity}}
|
|
||||||
* **Notes:** {{notes}}
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Step 0: Fetch the Advisory Details
|
|
||||||
|
|
||||||
Pull the full advisory so you understand the vulnerability before doing anything else:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Read the `description`, `severity`, `cvss`, and `vulnerabilities` fields. Understand the attack vector before writing code.
|
|
||||||
|
|
||||||
## Step 1: Acknowledge the Report
|
|
||||||
|
|
||||||
⚠️ **This step requires a human.** The advisory thread does not have a comment API. Ask the human operator to post a comment on the private advisory thread acknowledging the report. Provide them this template:
|
|
||||||
|
|
||||||
> Thanks for the report, @{{reporterHandle}}. We've confirmed the issue and are working on a fix. We're targeting a patch release within {{timeframe}}. We'll keep you updated here.
|
|
||||||
|
|
||||||
Give your human this template, but still continue
|
|
||||||
|
|
||||||
Below we use `gh` tools - you do have access and credentials outside of your sandbox, so use them.
|
|
||||||
|
|
||||||
## Step 2: Create the Temporary Private Fork
|
|
||||||
|
|
||||||
This is where all fix development happens. Never push to the public repo.
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api --method POST \
|
|
||||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/forks
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
This returns a repository object for the private fork. Save the `full_name` and `clone_url`.
|
|
||||||
|
|
||||||
Clone it and set up your workspace:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Clone the private fork somewhere outside ~/paperclip
|
|
||||||
git clone <clone_url_from_response> ~/security-patch-{{ghsaId}}
|
|
||||||
cd ~/security-patch-{{ghsaId}}
|
|
||||||
git checkout -b security-fix
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**Do not edit `~/paperclip`** — the dev server is running off the `~/paperclip` master branch and we don't want to touch it. All work happens in the private fork clone.
|
|
||||||
|
|
||||||
**TIPS:**
|
|
||||||
|
|
||||||
* Do not commit `pnpm-lock.yaml` — the repo has actions to manage this
|
|
||||||
* Do not use descriptive branch names that leak the vulnerability (e.g., no `fix-dns-rebinding-rce`). Use something generic like `security-fix`
|
|
||||||
* All work stays in the private fork until publication
|
|
||||||
* CI/GitHub Actions will NOT run on the temporary private fork — this is a GitHub limitation by design. You must run tests locally
|
|
||||||
|
|
||||||
## Step 3: Develop and Validate the Fix
|
|
||||||
|
|
||||||
Write the patch. Same content standards as any PR:
|
|
||||||
|
|
||||||
* It must functionally work — **run tests locally** since CI won't run on the private fork
|
|
||||||
* Consider the whole codebase, not just the narrow vulnerability path. A patch that fixes one vector but opens another is worse than no patch
|
|
||||||
* Ensure backwards compatibility for the database, or be explicit about what breaks
|
|
||||||
* Make sure any UI components still look correct if the fix touches them
|
|
||||||
* The fix should be minimal and focused — don't bundle unrelated changes into a security patch. Reviewers (and the reporter) should be able to read the diff and understand exactly what changed and why
|
|
||||||
|
|
||||||
**Specific to security fixes:**
|
|
||||||
|
|
||||||
* Verify the fix actually closes the attack vector described in the advisory. Reproduce the vulnerability first (using the reporter's description), then confirm the patch prevents it
|
|
||||||
* Consider adjacent attack vectors — if DNS rebinding is the issue, are there other endpoints or modes with the same class of problem?
|
|
||||||
* Do not introduce new dependencies unless absolutely necessary — new deps in a security patch raise eyebrows
|
|
||||||
|
|
||||||
Push your fix to the private fork:
|
|
||||||
|
|
||||||
```
|
|
||||||
git add -A
|
|
||||||
git commit -m "Fix security vulnerability"
|
|
||||||
git push origin security-fix
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 4: Coordinate with the Reporter
|
|
||||||
|
|
||||||
⚠️ **This step requires a human.** Ask the human operator to post on the advisory thread letting the reporter know the fix is ready and giving them a chance to review. Provide them this template:
|
|
||||||
|
|
||||||
> @{{reporterHandle}} — fix is ready in the private fork if you'd like to review before we publish. Planning to release within {{timeframe}}.
|
|
||||||
|
|
||||||
Proceed
|
|
||||||
|
|
||||||
## Step 5: Request a CVE
|
|
||||||
|
|
||||||
This makes vulnerability scanners (npm audit, Snyk, Dependabot) warn users to upgrade. Without it, nobody gets automated notification.
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api --method POST \
|
|
||||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/cve
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
GitHub is a CVE Numbering Authority and will assign one automatically. The CVE may take a few hours to propagate after the advisory is published.
|
|
||||||
|
|
||||||
## Step 6: Publish Everything Simultaneously
|
|
||||||
|
|
||||||
This all happens at once — do not stagger these steps. The goal is **zero window** between the vulnerability becoming public knowledge and the fix being available.
|
|
||||||
|
|
||||||
### 6a. Verify reporter credit before publishing
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} --jq '.credits'
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
If the reporter is not credited, add them:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api --method PATCH \
|
|
||||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
|
|
||||||
--input - << 'EOF'
|
|
||||||
{
|
|
||||||
"credits": [
|
|
||||||
{
|
|
||||||
"login": "{{reporterHandle}}",
|
|
||||||
"type": "reporter"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6b. Update the advisory with the patched version and publish
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api --method PATCH \
|
|
||||||
repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
|
|
||||||
--input - << 'EOF'
|
|
||||||
{
|
|
||||||
"state": "published",
|
|
||||||
"vulnerabilities": [
|
|
||||||
{
|
|
||||||
"package": {
|
|
||||||
"ecosystem": "npm",
|
|
||||||
"name": "paperclip"
|
|
||||||
},
|
|
||||||
"vulnerable_version_range": "< {{patchedVersion}}",
|
|
||||||
"patched_versions": "{{patchedVersion}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Publishing the advisory simultaneously:
|
|
||||||
|
|
||||||
* Makes the GHSA public
|
|
||||||
* Merges the temporary private fork into your repo
|
|
||||||
* Triggers the CVE assignment (if requested in step 5)
|
|
||||||
|
|
||||||
### 6c. Cut a release immediately after merge
|
|
||||||
|
|
||||||
```
|
|
||||||
cd ~/paperclip
|
|
||||||
git pull origin master
|
|
||||||
|
|
||||||
gh release create v{{patchedVersion}} \
|
|
||||||
--repo paperclipai/paperclip \
|
|
||||||
--title "v{{patchedVersion}} — Security Release" \
|
|
||||||
--notes "## Security Release
|
|
||||||
|
|
||||||
This release fixes a critical security vulnerability.
|
|
||||||
|
|
||||||
### What was fixed
|
|
||||||
{{briefDescription}} (e.g., Remote code execution via DNS rebinding in \`local_trusted\` mode)
|
|
||||||
|
|
||||||
### Advisory
|
|
||||||
https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}
|
|
||||||
|
|
||||||
### Credit
|
|
||||||
Thanks to @{{reporterHandle}} for responsibly disclosing this vulnerability.
|
|
||||||
|
|
||||||
### Action required
|
|
||||||
All users running versions prior to {{patchedVersion}} should upgrade immediately."
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 7: Post-Publication Verification
|
|
||||||
|
|
||||||
```
|
|
||||||
# Verify the advisory is published and CVE is assigned
|
|
||||||
gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \
|
|
||||||
--jq '{state: .state, cve_id: .cve_id, published_at: .published_at}'
|
|
||||||
|
|
||||||
# Verify the release exists
|
|
||||||
gh release view v{{patchedVersion}} --repo paperclipai/paperclip
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
If the CVE hasn't been assigned yet, that's normal — it can take a few hours.
|
|
||||||
|
|
||||||
⚠️ **Human step:** Ask the human operator to post a final comment on the advisory thread confirming publication and thanking the reporter.
|
|
||||||
|
|
||||||
Tell the human operator what you did by posting a comment to this task, including:
|
|
||||||
|
|
||||||
* The published advisory URL: `https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}`
|
|
||||||
* The release URL
|
|
||||||
* Whether the CVE has been assigned yet
|
|
||||||
* All URLs to any pull requests or branches
|
|
||||||
@@ -177,12 +177,8 @@ real name or email). To find GitHub usernames:
|
|||||||
|
|
||||||
**Never expose contributor email addresses.** Use `@username` only.
|
**Never expose contributor email addresses.** Use `@username` only.
|
||||||
|
|
||||||
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list.
|
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors
|
||||||
Exclude Paperclip founders from the list (e.g. `cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`)
|
in alphabetical order by GitHub username (case-insensitive).
|
||||||
|
|
||||||
List contributors in alphabetical order by GitHub username (case-insensitive).
|
|
||||||
|
|
||||||
If there are no contributors left after exclusions, then just skip this section and don't mention it.
|
|
||||||
|
|
||||||
## Step 6 — Review Before Release
|
## Step 6 — Review Before Release
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,3 @@ DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
|
|||||||
PORT=3100
|
PORT=3100
|
||||||
SERVE_UI=false
|
SERVE_UI=false
|
||||||
BETTER_AUTH_SECRET=paperclip-dev-secret
|
BETTER_AUTH_SECRET=paperclip-dev-secret
|
||||||
|
|
||||||
# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh)
|
|
||||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
name: "Build: Dev"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: runners-farhoodlabs
|
|
||||||
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: Login to Docker Hub
|
|
||||||
continue-on-error: true
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ghcr.io/farhoodlabs/paperclip-dev
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest
|
|
||||||
type=sha,prefix=
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: .farhoodlabs/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
no-cache: true
|
|
||||||
|
|
||||||
update-infra:
|
|
||||||
needs: build
|
|
||||||
runs-on: runners-farhoodlabs
|
|
||||||
steps:
|
|
||||||
- name: Generate app token
|
|
||||||
id: app-token
|
|
||||||
uses: actions/create-github-app-token@v1
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.PAPERCLIP_APP_ID }}
|
|
||||||
private-key: ${{ secrets.PAPERCLIP_APP_PRIVATE_KEY }}
|
|
||||||
repositories: paperclip-infra
|
|
||||||
|
|
||||||
- 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: Bearer ${{ steps.app-token.outputs.token }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
"https://api.github.com/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: Bearer ${{ steps.app-token.outputs.token }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
|
||||||
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
name: "Build: Production"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [local]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: runners-farhoodlabs
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ghcr.io/farhoodlabs/paperclip
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest
|
|
||||||
type=sha,prefix=
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
no-cache: true
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Paperclip Fork — Project Context
|
|
||||||
|
|
||||||
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
|
||||||
Fork repo: https://github.com/farhoodlabs/paperclip
|
|
||||||
|
|
||||||
## Branch Model
|
|
||||||
|
|
||||||
| Branch | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `master` | Mirrors `upstream/master` exactly + `.farhoodlabs/` overlay directory + `assemble-local.yml` action. Never commit application code here. |
|
|
||||||
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `ghcr.io/farhoodlabs/paperclip`. |
|
|
||||||
| `dev` | Development branch based on upstream/master. Builds `ghcr.io/farhoodlabs/paperclip-dev` on every push. |
|
|
||||||
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
|
|
||||||
|
|
||||||
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
|
|
||||||
|
|
||||||
## Fork Overlay (`.farhoodlabs/`)
|
|
||||||
|
|
||||||
Files committed to `master` that get copied into position on `local` by the assemble action:
|
|
||||||
|
|
||||||
```
|
|
||||||
.farhoodlabs/
|
|
||||||
CLAUDE.md → CLAUDE.md (repo root)
|
|
||||||
Dockerfile → Dockerfile
|
|
||||||
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
|
|
||||||
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
|
|
||||||
|
|
||||||
To modify fork-specific files, edit them in `.farhoodlabs/` on `master` and push — the assemble action will apply them to `local` automatically.
|
|
||||||
|
|
||||||
## Pending Upstream PRs (included in `local`)
|
|
||||||
|
|
||||||
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
|
|
||||||
|
|
||||||
| PR | Branch | Method | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
|
|
||||||
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
|
|
||||||
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Sync upstream into master
|
|
||||||
```bash
|
|
||||||
git fetch upstream
|
|
||||||
git push origin upstream/master:master --force-with-lease
|
|
||||||
# assemble-local.yml triggers automatically and rebuilds local
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add a new pending PR to local
|
|
||||||
Edit `.github/workflows/assemble-local.yml` on `master`:
|
|
||||||
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
|
|
||||||
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
|
|
||||||
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
|
|
||||||
|
|
||||||
### Remove a PR after upstream merges it
|
|
||||||
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `master`.
|
|
||||||
|
|
||||||
### Submit a new PR to upstream
|
|
||||||
Branch from `upstream/master` (not from `local` or `master`):
|
|
||||||
```bash
|
|
||||||
git fetch upstream
|
|
||||||
git checkout -b feat/my-feature upstream/master
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modify the fork Dockerfile
|
|
||||||
Edit `.farhoodlabs/Dockerfile` on `master`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `ghcr.io/farhoodlabs/paperclip:latest`.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
|
|
||||||
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `master`
|
|
||||||
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1.20
|
|
||||||
FROM node:lts-trixie-slim AS base
|
|
||||||
ARG USER_UID=1000
|
|
||||||
ARG USER_GID=1000
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& corepack enable
|
|
||||||
|
|
||||||
# Modify the existing node user/group to have the specified UID/GID to match host user
|
|
||||||
RUN usermod -u $USER_UID --non-unique node \
|
|
||||||
&& groupmod -g $USER_GID --non-unique node \
|
|
||||||
&& usermod -g $USER_GID -d /paperclip node
|
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
|
|
||||||
COPY cli/package.json cli/
|
|
||||||
COPY server/package.json server/
|
|
||||||
COPY ui/package.json ui/
|
|
||||||
COPY packages/shared/package.json packages/shared/
|
|
||||||
COPY packages/db/package.json packages/db/
|
|
||||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
|
||||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
|
||||||
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/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
|
||||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
|
||||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
|
||||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
|
||||||
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
|
|
||||||
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
|
|
||||||
COPY patches/ patches/
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
FROM base AS build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app /app
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm --filter @paperclipai/ui build
|
|
||||||
RUN pnpm --filter @paperclipai/plugin-sdk build
|
|
||||||
RUN pnpm --filter @paperclipai/server build
|
|
||||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
|
||||||
|
|
||||||
FROM base AS production
|
|
||||||
ARG USER_UID=1000
|
|
||||||
ARG USER_GID=1000
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --chown=node:node --from=build /app /app
|
|
||||||
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools
|
|
||||||
# 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 \
|
|
||||||
&& mkdir -p /paperclip \
|
|
||||||
&& chown node:node /paperclip
|
|
||||||
|
|
||||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
HOME=/paperclip \
|
|
||||||
HOST=0.0.0.0 \
|
|
||||||
PORT=3100 \
|
|
||||||
SERVE_UI=true \
|
|
||||||
PAPERCLIP_HOME=/paperclip \
|
|
||||||
PAPERCLIP_INSTANCE_ID=default \
|
|
||||||
USER_UID=${USER_UID} \
|
|
||||||
USER_GID=${USER_GID} \
|
|
||||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
|
|
||||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
|
|
||||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
|
|
||||||
OPENCODE_ALLOW_ALL_MODELS=true
|
|
||||||
|
|
||||||
VOLUME ["/paperclip"]
|
|
||||||
EXPOSE 3100
|
|
||||||
|
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
|
||||||
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
|
|
||||||
@@ -38,8 +38,6 @@
|
|||||||
|
|
||||||
-
|
-
|
||||||
|
|
||||||
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`.
|
|
||||||
|
|
||||||
## Model Used
|
## Model Used
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -59,7 +57,6 @@
|
|||||||
|
|
||||||
- [ ] I have included a thinking path that traces from project context to this change
|
- [ ] I have included a thinking path that traces from project context to this change
|
||||||
- [ ] I have specified the model used (with version and capability details)
|
- [ ] I have specified the model used (with version and capability details)
|
||||||
- [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
|
|
||||||
- [ ] I have run tests locally and they pass
|
- [ ] I have run tests locally and they pass
|
||||||
- [ ] I have added or updated tests where applicable
|
- [ ] I have added or updated tests where applicable
|
||||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
name: Assemble local branch
|
|
||||||
|
|
||||||
# Triggers on every master push (i.e. after syncing upstream) and on demand.
|
|
||||||
# Builds the `local` branch: master + fork overlay + cherry-picked pending upstream PRs.
|
|
||||||
# Syncs build-dev.yml to the `dev` branch so every dev push triggers a build.
|
|
||||||
#
|
|
||||||
# PR entries support an optional "exclude:BRANCH" suffix to handle cases where
|
|
||||||
# one PR branch was rebased onto another. The exclude branch's commits are subtracted
|
|
||||||
# from the cherry-pick range so they aren't double-applied.
|
|
||||||
#
|
|
||||||
# When upstream merges a PR, remove its entry from PR_CHERRY_PICK or PR_SQUASH below.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
assemble:
|
|
||||||
runs-on: runners-farhoodlabs
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Checkout master
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Configure git
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Fetch all remotes
|
|
||||||
run: |
|
|
||||||
git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true
|
|
||||||
git fetch --all --quiet
|
|
||||||
|
|
||||||
- name: Assemble local branch
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Start local from master (which mirrors upstream)
|
|
||||||
git checkout -B local origin/master
|
|
||||||
|
|
||||||
# Apply fork overlay: Dockerfile, build workflows, CLAUDE.md
|
|
||||||
cp .farhoodlabs/Dockerfile Dockerfile
|
|
||||||
cp .farhoodlabs/CLAUDE.md CLAUDE.md
|
|
||||||
mkdir -p .github/workflows
|
|
||||||
cp .farhoodlabs/.github/workflows/build-prod.yml .github/workflows/build-prod.yml
|
|
||||||
cp .farhoodlabs/.github/workflows/build-dev.yml .github/workflows/build-dev.yml
|
|
||||||
git add Dockerfile CLAUDE.md .github/workflows/build-prod.yml .github/workflows/build-dev.yml
|
|
||||||
git commit -m "chore: apply fork overlay from .farhoodlabs"
|
|
||||||
|
|
||||||
# --- PRs to cherry-pick commit-by-commit (clean, no merge commits) ---
|
|
||||||
# Format: "PR-number branch-name [exclude:base-branch]"
|
|
||||||
# Use exclude: when a branch was rebased onto another PR branch to avoid double-applying commits.
|
|
||||||
# Remove an entry here when upstream merges the PR.
|
|
||||||
PR_CHERRY_PICK=(
|
|
||||||
"3237 skill-pat-feature"
|
|
||||||
"3351 skill-scan-refresh exclude:skill-pat-feature"
|
|
||||||
"4162 fix/far-108-k8s-adapter-reaper-liveness"
|
|
||||||
)
|
|
||||||
|
|
||||||
for entry in "${PR_CHERRY_PICK[@]}"; do
|
|
||||||
# Parse: pr_num, branch, optional exclude branch
|
|
||||||
pr_num=$(echo "$entry" | awk '{print $1}')
|
|
||||||
branch=$(echo "$entry" | awk '{print $2}')
|
|
||||||
exclude_branch=$(echo "$entry" | grep -oP '(?<=exclude:)\S+' || true)
|
|
||||||
remote_branch="origin/$branch"
|
|
||||||
exclude_arg=""
|
|
||||||
if [ -n "$exclude_branch" ]; then
|
|
||||||
exclude_arg="--not origin/$exclude_branch"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git rev-parse "$remote_branch" &>/dev/null; then
|
|
||||||
echo "WARNING: $remote_branch not found, skipping PR #$pr_num"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Exclude commits already on origin/master (fork-overlay/CI infra
|
|
||||||
# that landed on master via the .farhoodlabs/ overlay path). PR
|
|
||||||
# branches sometimes pull these in via `git merge origin/master`,
|
|
||||||
# but cherry-picking them onto `local` (which is already master)
|
|
||||||
# is redundant and produces conflicts on the assemble-local file.
|
|
||||||
mapfile -t commits < <(git log --no-merges --reverse --format="%H" upstream/master.."$remote_branch" ^origin/master $exclude_arg)
|
|
||||||
|
|
||||||
if [ ${#commits[@]} -eq 0 ]; then
|
|
||||||
echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "PR #$pr_num ($branch): cherry-picking ${#commits[@]} commit(s)"
|
|
||||||
for sha in "${commits[@]}"; do
|
|
||||||
git cherry-pick "$sha" || {
|
|
||||||
# If the cherry-pick produced an empty result (commit's content
|
|
||||||
# is already in HEAD via auto-merge), skip it instead of failing.
|
|
||||||
# State signature: CHERRY_PICK_HEAD set, no unmerged paths,
|
|
||||||
# nothing staged.
|
|
||||||
if [ -f .git/CHERRY_PICK_HEAD ] \
|
|
||||||
&& [ -z "$(git diff --name-only --diff-filter=U)" ] \
|
|
||||||
&& git diff --staged --quiet; then
|
|
||||||
echo "PR #$pr_num: $sha became empty after merge, skipping"
|
|
||||||
git cherry-pick --skip
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
echo "::error::Cherry-pick conflict at $sha from PR #$pr_num ($branch)"
|
|
||||||
echo "::error::Resolve the conflict, force-push the branch, then re-run this workflow"
|
|
||||||
git cherry-pick --abort
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- PRs to apply as a single squash (complex history with merge commits) ---
|
|
||||||
# git merge --squash applies the net final diff of the branch, bypassing
|
|
||||||
# intra-PR commit ordering issues. CI commits that cancel out are ignored.
|
|
||||||
# Remove an entry here when upstream merges the PR.
|
|
||||||
PR_SQUASH=(
|
|
||||||
"3987 feat/company-portability-complete"
|
|
||||||
)
|
|
||||||
|
|
||||||
for entry in "${PR_SQUASH[@]}"; do
|
|
||||||
pr_num="${entry%% *}"
|
|
||||||
branch="${entry#* }"
|
|
||||||
remote_branch="origin/$branch"
|
|
||||||
|
|
||||||
if ! git rev-parse "$remote_branch" &>/dev/null; then
|
|
||||||
echo "WARNING: $remote_branch not found, skipping PR #$pr_num"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if the branch has any unique non-merge commits
|
|
||||||
unique=$(git log --no-merges --oneline upstream/master.."$remote_branch" | wc -l)
|
|
||||||
if [ "$unique" -eq 0 ]; then
|
|
||||||
echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "PR #$pr_num ($branch): applying as squash ($unique non-merge commits)"
|
|
||||||
git merge --squash "$remote_branch" || {
|
|
||||||
echo "::error::Squash conflict for PR #$pr_num ($branch)"
|
|
||||||
git merge --abort 2>/dev/null || git reset --hard HEAD
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
# Only commit if there are staged changes
|
|
||||||
git diff --staged --quiet || git commit -m "feat: apply PR #$pr_num ($branch)"
|
|
||||||
done
|
|
||||||
|
|
||||||
git push origin local --force
|
|
||||||
echo "local branch assembled and pushed"
|
|
||||||
|
|
||||||
- name: Trigger prod build
|
|
||||||
run: |
|
|
||||||
curl -sS -X POST \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-prod.yml/dispatches \
|
|
||||||
-d '{"ref":"local"}'
|
|
||||||
|
|
||||||
- name: Sync build-dev.yml to dev branch
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if ! git rev-parse origin/dev &>/dev/null; then
|
|
||||||
echo "dev branch not found on origin, skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
canonical=".farhoodlabs/.github/workflows/build-dev.yml"
|
|
||||||
target=".github/workflows/build-dev.yml"
|
|
||||||
|
|
||||||
if git show origin/dev:"$target" 2>/dev/null | diff --brief - "$canonical" &>/dev/null; then
|
|
||||||
echo "build-dev.yml on dev is up to date, skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Syncing build-dev.yml to dev branch..."
|
|
||||||
# Save canonical content before switching branches (.farhoodlabs/ only exists on master)
|
|
||||||
tmp=$(mktemp)
|
|
||||||
cp "$canonical" "$tmp"
|
|
||||||
git checkout -B dev-wf-sync origin/dev
|
|
||||||
mkdir -p "$(dirname "$target")"
|
|
||||||
cp "$tmp" "$target"
|
|
||||||
rm "$tmp"
|
|
||||||
git add "$target"
|
|
||||||
git commit -m "chore(ci): sync build-dev.yml from .farhoodlabs"
|
|
||||||
git push origin dev-wf-sync:dev
|
|
||||||
echo "build-dev.yml synced to dev"
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
name: "Build: Dev"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: runners-farhoodlabs
|
|
||||||
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: Login to Docker Hub
|
|
||||||
continue-on-error: true
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ghcr.io/farhoodlabs/paperclip-dev
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest
|
|
||||||
type=sha,prefix=
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: .farhoodlabs/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
no-cache: true
|
|
||||||
|
|
||||||
update-infra:
|
|
||||||
needs: build
|
|
||||||
runs-on: runners-farhoodlabs
|
|
||||||
steps:
|
|
||||||
- name: Generate app token
|
|
||||||
id: app-token
|
|
||||||
uses: actions/create-github-app-token@v1
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.PAPERCLIP_APP_ID }}
|
|
||||||
private-key: ${{ secrets.PAPERCLIP_APP_PRIVATE_KEY }}
|
|
||||||
repositories: paperclip-infra
|
|
||||||
|
|
||||||
- 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: Bearer ${{ steps.app-token.outputs.token }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
"https://api.github.com/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: Bearer ${{ steps.app-token.outputs.token }}" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
|
||||||
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
name: "Build: Production"
|
name: Build & Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [local]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -11,7 +11,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: runners-farhoodlabs
|
runs-on: runners-farhoodliquor
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/farhoodlabs/paperclip
|
images: ghcr.io/farhoodliquor/paperclip
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Disabled in fork — Docker builds are handled by the fork overlay:
|
|
||||||
# build-prod.yml triggers on `local` branch → ghcr.io/farhoodlabs/paperclip
|
|
||||||
# build-dev.yml triggers on `dev` branch → ghcr.io/farhoodlabs/paperclip-dev
|
|
||||||
# See .farhoodlabs/.github/workflows/ and .github/workflows/assemble-local.yml
|
|
||||||
#
|
|
||||||
# NOTE: upstream may overwrite this file when master is synced. Re-apply if that happens,
|
|
||||||
# or use the sync-upstream.yml action which re-applies these overrides automatically.
|
|
||||||
name: Docker
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
note:
|
|
||||||
description: "Disabled in fork. Use build-prod.yml (local) or build-dev.yml (dev)."
|
|
||||||
required: false
|
|
||||||
jobs:
|
|
||||||
disabled:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo "Disabled. See build-prod.yml and build-dev.yml."
|
|
||||||
@@ -23,9 +23,7 @@ jobs:
|
|||||||
- name: Block manual lockfile edits
|
- name: Block manual lockfile edits
|
||||||
if: github.head_ref != 'chore/refresh-lockfile'
|
if: github.head_ref != 'chore/refresh-lockfile'
|
||||||
run: |
|
run: |
|
||||||
# Diff the PR branch against its merge base so recent base-branch commits
|
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||||
# do not masquerade as changes made by the PR itself.
|
|
||||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
|
|
||||||
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
||||||
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -43,20 +41,48 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
- name: Validate Dockerfile deps stage
|
- name: Validate Dockerfile deps stage
|
||||||
run: node ./scripts/check-docker-deps-stage.mjs
|
|
||||||
|
|
||||||
- name: Validate release package manifest
|
|
||||||
run: node ./scripts/release-package-map.mjs check
|
|
||||||
|
|
||||||
- name: Verify release package bootstrap for changed manifests
|
|
||||||
run: |
|
run: |
|
||||||
mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")
|
missing=0
|
||||||
PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \
|
|
||||||
node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}"
|
# Extract only the deps stage from the Dockerfile
|
||||||
|
deps_stage="$(awk '/^FROM .* AS deps$/{found=1; next} found && /^FROM /{exit} found{print}' Dockerfile)"
|
||||||
|
|
||||||
|
if [ -z "$deps_stage" ]; then
|
||||||
|
echo "::error::Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Derive workspace search roots from pnpm-workspace.yaml (exclude dev-only packages)
|
||||||
|
search_roots="$(grep '^ *- ' pnpm-workspace.yaml | sed 's/^ *- //' | sed 's/\*$//' | grep -v 'examples' | grep -v 'create-paperclip-plugin' | tr '\n' ' ')"
|
||||||
|
|
||||||
|
if [ -z "$search_roots" ]; then
|
||||||
|
echo "::error::Could not derive workspace roots from pnpm-workspace.yaml"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check all workspace package.json files are copied in the deps stage
|
||||||
|
for pkg in $(find $search_roots -maxdepth 2 -name package.json -not -path '*/examples/*' -not -path '*/create-paperclip-plugin/*' -not -path '*/node_modules/*' 2>/dev/null | sort -u); do
|
||||||
|
dir="$(dirname "$pkg")"
|
||||||
|
if ! echo "$deps_stage" | grep -q "^COPY ${dir}/package.json"; then
|
||||||
|
echo "::error::Dockerfile deps stage missing: COPY ${pkg} ${dir}/"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check patches directory is copied if it exists
|
||||||
|
if [ -d patches ] && ! echo "$deps_stage" | grep -q '^COPY patches/'; then
|
||||||
|
echo "::error::Dockerfile deps stage missing: COPY patches/ patches/"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$missing" -eq 1 ]; then
|
||||||
|
echo "Dockerfile deps stage is out of sync. Update it to include the missing files."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Validate dependency resolution when manifests change
|
- name: Validate dependency resolution when manifests change
|
||||||
run: |
|
run: |
|
||||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
|
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||||
@@ -85,88 +111,16 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Typecheck workspaces whose build scripts skip TypeScript
|
- name: Typecheck
|
||||||
run: pnpm run typecheck:build-gaps
|
run: pnpm -r typecheck
|
||||||
|
|
||||||
- name: Run general test suites
|
- name: Run tests
|
||||||
run: pnpm test:run:general
|
run: pnpm test:run
|
||||||
|
|
||||||
- name: Verify release registry test coverage
|
|
||||||
run: pnpm run test:release-registry
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
verify_serialized_server:
|
- name: Release canary dry run
|
||||||
name: Verify serialized server suites (${{ matrix.shard_label }})
|
|
||||||
needs: [policy]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- shard_index: 0
|
|
||||||
shard_count: 4
|
|
||||||
shard_label: 1/4
|
|
||||||
- shard_index: 1
|
|
||||||
shard_count: 4
|
|
||||||
shard_label: 2/4
|
|
||||||
- shard_index: 2
|
|
||||||
shard_count: 4
|
|
||||||
shard_label: 3/4
|
|
||||||
- shard_index: 3
|
|
||||||
shard_count: 4
|
|
||||||
shard_label: 4/4
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9.15.4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Run serialized server test shard
|
|
||||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
|
||||||
|
|
||||||
canary_dry_run:
|
|
||||||
name: Canary Dry Run
|
|
||||||
needs: [policy]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9.15.4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# `release.sh` always executes its Step 2/7 workspace build, even when
|
|
||||||
# `--skip-verify` bypasses the initial verification gate.
|
|
||||||
- name: Release canary dry run via release.sh internal build
|
|
||||||
run: |
|
run: |
|
||||||
git checkout -B master HEAD
|
git checkout -B master HEAD
|
||||||
git checkout -- pnpm-lock.yaml
|
git checkout -- pnpm-lock.yaml
|
||||||
@@ -195,6 +149,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
run: npx playwright install --with-deps chromium
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Disabled in fork — package publishing is not applicable to this fork.
|
|
||||||
#
|
|
||||||
# NOTE: upstream may overwrite this file when master is synced. Re-apply if that happens,
|
|
||||||
# or use the sync-upstream.yml action which re-applies these overrides automatically.
|
|
||||||
name: Release
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
note:
|
|
||||||
description: "Disabled in fork."
|
|
||||||
required: false
|
|
||||||
jobs:
|
|
||||||
disabled:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo "Disabled in fork."
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
name: Sync upstream
|
|
||||||
|
|
||||||
# Syncs upstream/master into this fork's master, then re-applies fork overrides
|
|
||||||
# for any upstream workflow files that should not run in the fork (docker.yml, release.yml).
|
|
||||||
# Triggers assemble-local.yml automatically via the master push.
|
|
||||||
#
|
|
||||||
# Run manually or on a schedule to keep master current with upstream.
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 7 * * *' # daily at 2am EST (UTC-5)
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync:
|
|
||||||
runs-on: runners-farhoodlabs
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout master
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Configure git
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Fetch upstream
|
|
||||||
run: |
|
|
||||||
git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true
|
|
||||||
git fetch upstream
|
|
||||||
|
|
||||||
- name: Fast-forward master to upstream
|
|
||||||
run: |
|
|
||||||
git merge --ff-only upstream/master || {
|
|
||||||
echo "::error::Cannot fast-forward master to upstream/master — diverged history"
|
|
||||||
echo "::error::Resolve manually: git fetch upstream && git rebase upstream/master"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Re-apply fork workflow overrides
|
|
||||||
run: |
|
|
||||||
# These files are overridden in the fork to prevent upstream workflows from
|
|
||||||
# running on fork pushes. Re-apply after each upstream sync.
|
|
||||||
OVERRIDE_FILES=(
|
|
||||||
".github/workflows/docker.yml"
|
|
||||||
".github/workflows/release.yml"
|
|
||||||
)
|
|
||||||
changed=false
|
|
||||||
for f in "${OVERRIDE_FILES[@]}"; do
|
|
||||||
fork_version=$(git show origin/master:"$f" 2>/dev/null || true)
|
|
||||||
current=$(cat "$f" 2>/dev/null || true)
|
|
||||||
if [ "$fork_version" != "$current" ]; then
|
|
||||||
echo "Re-applying fork override: $f"
|
|
||||||
git checkout origin/master -- "$f"
|
|
||||||
changed=true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$changed" = true ]; then
|
|
||||||
git add "${OVERRIDE_FILES[@]}"
|
|
||||||
git commit -m "chore(ci): re-apply fork workflow overrides after upstream sync"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push master
|
|
||||||
run: git push origin master
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
node_modules
|
|
||||||
node_modules/
|
node_modules/
|
||||||
**/node_modules
|
|
||||||
**/node_modules/
|
|
||||||
dist/
|
dist/
|
||||||
ui/storybook-static/
|
|
||||||
.env
|
.env
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
drizzle/meta/
|
drizzle/meta/
|
||||||
@@ -36,7 +32,6 @@ server/src/**/*.d.ts
|
|||||||
server/src/**/*.d.ts.map
|
server/src/**/*.d.ts.map
|
||||||
tmp/
|
tmp/
|
||||||
feedback-export-*
|
feedback-export-*
|
||||||
diagnostics/
|
|
||||||
|
|
||||||
# Editor / tool temp files
|
# Editor / tool temp files
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
Dotta <bippadotta@protonmail.com> <34892728+cryppadotta@users.noreply.github.com>
|
Dotta <bippadotta@protonmail.com> Forgotten <forgottenrunes@protonmail.com>
|
||||||
Dotta <bippadotta@protonmail.com> <forgottenrunes@protonmail.com>
|
|
||||||
Dotta <bippadotta@protonmail.com> <dotta@example.com>
|
|
||||||
|
|||||||
@@ -108,24 +108,7 @@ Notes:
|
|||||||
|
|
||||||
## 7. Verification Before Hand-off
|
## 7. Verification Before Hand-off
|
||||||
|
|
||||||
Default local/agent test path:
|
Run this full check before claiming done:
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the cheap default and only runs the Vitest suite. Browser suites stay opt-in:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test:e2e
|
|
||||||
pnpm test:release-smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the browser suites only when your change touches them or when you are explicitly verifying CI/release flows.
|
|
||||||
|
|
||||||
For normal issue work, run the smallest relevant verification first. Do not default to repo-wide typecheck/build/test on every heartbeat when a narrower check is enough to prove the change.
|
|
||||||
|
|
||||||
Run this full check before claiming repo work done in a PR-ready hand-off, or when the change scope is broad enough that targeted checks are not sufficient:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm -r typecheck
|
pnpm -r typecheck
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Paperclip Fork — Project Context
|
|
||||||
|
|
||||||
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
|
||||||
Fork repo: https://github.com/farhoodlabs/paperclip
|
|
||||||
|
|
||||||
## Branch Model
|
|
||||||
|
|
||||||
| Branch | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `master` | Mirrors `upstream/master` exactly + `.farhoodlabs/` overlay directory + `assemble-local.yml` action. Never commit application code here. |
|
|
||||||
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `ghcr.io/farhoodlabs/paperclip`. |
|
|
||||||
| `dev` | Development branch based on upstream/master. Builds `ghcr.io/farhoodlabs/paperclip-dev` on every push. |
|
|
||||||
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
|
|
||||||
|
|
||||||
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
|
|
||||||
|
|
||||||
## Fork Overlay (`.farhoodlabs/`)
|
|
||||||
|
|
||||||
Files committed to `master` that get copied into position on `local` by the assemble action:
|
|
||||||
|
|
||||||
```
|
|
||||||
.farhoodlabs/
|
|
||||||
CLAUDE.md → CLAUDE.md (repo root)
|
|
||||||
Dockerfile → Dockerfile
|
|
||||||
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
|
|
||||||
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
|
|
||||||
|
|
||||||
To modify fork-specific files, edit them in `.farhoodlabs/` on `master` and push — the assemble action will apply them to `local` automatically.
|
|
||||||
|
|
||||||
## Pending Upstream PRs (included in `local`)
|
|
||||||
|
|
||||||
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
|
|
||||||
|
|
||||||
| PR | Branch | Method | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
|
|
||||||
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
|
|
||||||
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Sync upstream into master
|
|
||||||
```bash
|
|
||||||
git fetch upstream
|
|
||||||
git push origin upstream/master:master --force-with-lease
|
|
||||||
# assemble-local.yml triggers automatically and rebuilds local
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add a new pending PR to local
|
|
||||||
Edit `.github/workflows/assemble-local.yml` on `master`:
|
|
||||||
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
|
|
||||||
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
|
|
||||||
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
|
|
||||||
|
|
||||||
### Remove a PR after upstream merges it
|
|
||||||
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `master`.
|
|
||||||
|
|
||||||
### Submit a new PR to upstream
|
|
||||||
Branch from `upstream/master` (not from `local` or `master`):
|
|
||||||
```bash
|
|
||||||
git fetch upstream
|
|
||||||
git checkout -b feat/my-feature upstream/master
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modify the fork Dockerfile
|
|
||||||
Edit `.farhoodlabs/Dockerfile` on `master`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `ghcr.io/farhoodlabs/paperclip:latest`.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
|
|
||||||
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `master`
|
|
||||||
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
|
|
||||||
@@ -51,21 +51,6 @@ All tests must pass before a PR can be merged. Run them locally first and verify
|
|||||||
|
|
||||||
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
|
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
|
||||||
|
|
||||||
## Feature Contributions
|
|
||||||
|
|
||||||
We actively manage the core Paperclip feature roadmap.
|
|
||||||
|
|
||||||
Uncoordinated feature PRs against the core product may be closed, even when the implementation is thoughtful and high quality. That is about roadmap ownership, product coherence, and long-term maintenance commitment, not a judgment about the effort.
|
|
||||||
|
|
||||||
If you want to contribute a feature:
|
|
||||||
|
|
||||||
- Check [ROADMAP.md](ROADMAP.md) first
|
|
||||||
- Start the discussion in Discord -> `#dev` before writing code
|
|
||||||
- If the idea fits as an extension, prefer building it with the [plugin system](doc/plugins/PLUGIN_SPEC.md)
|
|
||||||
- If you want to show a possible direction, reference implementations are welcome as feedback, but they generally will not be merged directly into core
|
|
||||||
|
|
||||||
Bugs, docs improvements, and small targeted improvements are still the easiest path to getting merged, and we really do appreciate them.
|
|
||||||
|
|
||||||
## General Rules (both paths)
|
## General Rules (both paths)
|
||||||
|
|
||||||
- Write clear commit messages
|
- Write clear commit messages
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# syntax=docker/dockerfile:1.20
|
|
||||||
FROM node:lts-trixie-slim AS base
|
FROM node:lts-trixie-slim AS base
|
||||||
ARG USER_UID=1000
|
ARG USER_UID=1000
|
||||||
ARG USER_GID=1000
|
ARG USER_GID=1000
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \
|
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 passwd \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \
|
||||||
|
&& mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \
|
||||||
|
&& rm -rf /tmp/gh_* \
|
||||||
&& corepack enable
|
&& corepack enable
|
||||||
|
|
||||||
# Modify the existing node user/group to have the specified UID/GID to match host user
|
# Modify the existing node user/group to have the specified UID/GID to match host user
|
||||||
@@ -22,19 +24,14 @@ COPY packages/shared/package.json packages/shared/
|
|||||||
COPY packages/db/package.json packages/db/
|
COPY packages/db/package.json packages/db/
|
||||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||||
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/claude-local/package.json packages/adapters/claude-local/
|
||||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-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/cursor-local/package.json packages/adapters/cursor-local/
|
||||||
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
||||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
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/opencode-local/package.json packages/adapters/opencode-local/
|
||||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
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 patches/ patches/
|
COPY patches/ patches/
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
@@ -53,10 +50,18 @@ ARG USER_UID=1000
|
|||||||
ARG USER_GID=1000
|
ARG USER_GID=1000
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --chown=node:node --from=build /app /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 \
|
RUN apt-get update \
|
||||||
&& apt-get update \
|
&& apt-get install -y --no-install-recommends jq nano vim \
|
||||||
&& apt-get install -y --no-install-recommends openssh-client jq \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& 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 \
|
||||||
|
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai hermes-paperclip-adapter \
|
||||||
&& mkdir -p /paperclip \
|
&& mkdir -p /paperclip \
|
||||||
&& chown node:node /paperclip
|
&& chown node:node /paperclip
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
||||||
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
||||||
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</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://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
|
||||||
<a href="https://x.com/papercliping"><strong>Twitter</strong></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -157,115 +156,6 @@ Paperclip handles the hard orchestration details correctly.
|
|||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## What's Under the Hood
|
|
||||||
|
|
||||||
Paperclip is a full control plane, not a wrapper. Before you build any of this yourself, know that it already exists:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ PAPERCLIP SERVER │
|
|
||||||
│ │
|
|
||||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
|
||||||
│ │Identity & │ │ Work & │ │ Heartbeat │ │Governance │ │
|
|
||||||
│ │ Access │ │ Tasks │ │ Execution │ │& Approvals│ │
|
|
||||||
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
|
||||||
│ │ Org Chart │ │Workspaces │ │ Plugins │ │ Budget │ │
|
|
||||||
│ │ & Agents │ │ & Runtime │ │ │ │ & Costs │ │
|
|
||||||
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
|
||||||
│ │ Routines │ │ Secrets & │ │ Activity │ │ Company │ │
|
|
||||||
│ │& Schedules│ │ Storage │ │ & Events │ │Portability│ │
|
|
||||||
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
▲ ▲ ▲ ▲
|
|
||||||
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
|
|
||||||
│ Claude │ │ Codex │ │ CLI │ │ HTTP/web │
|
|
||||||
│ Code │ │ │ │ agents │ │ bots │
|
|
||||||
└───────────┘ └───────────┘ └───────────┘ └───────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### The Systems
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
**Identity & Access** — Two deployment modes (trusted local or authenticated), board users, agent API keys, short-lived run JWTs, company memberships, invite flows, and OpenClaw onboarding. Every mutating request is traced to an actor.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td width="50%">
|
|
||||||
|
|
||||||
**Org Chart & Agents** — Agents have roles, titles, reporting lines, permissions, and budgets. Adapter examples match the diagram: Claude Code, Codex, CLI agents such as Cursor/Gemini/bash, HTTP/webhook bots such as OpenClaw, and external adapter plugins. If it can receive a heartbeat, it's hired.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Work & Task System** — Issues carry company/project/goal/parent links, atomic checkout with execution locks, first-class blocker dependencies, comments, documents, attachments, work products, labels, and inbox state. No double-work, no lost context.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Heartbeat Execution** — DB-backed wakeup queue with coalescing, budget checks, workspace resolution, secret injection, skill loading, and adapter invocation. Runs produce structured logs, cost events, session state, and audit trails. Recovery handles orphaned runs automatically.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Workspaces & Runtime** — Project workspaces, isolated execution workspaces (git worktrees, operator branches), and runtime services (dev servers, preview URLs). Agents work in the right directory with the right context every time.
|
|
||||||
|
|
||||||
</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.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Budget & Cost Control** — Token and cost tracking by company, agent, project, goal, issue, provider, and model. Scoped budget policies with warning thresholds and hard stops. Overspend pauses agents and cancels queued work automatically.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Routines & Schedules** — Recurring tasks with cron, webhook, and API triggers. Concurrency and catch-up policies. Each routine execution creates a tracked issue and wakes the assigned agent — no manual kick-offs needed.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Plugins** — Instance-wide plugin system with out-of-process workers, capability-gated host services, job scheduling, tool exposure, and UI contributions. Extend Paperclip without forking it.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Secrets & Storage** — Instance and company secrets, encrypted local storage, provider-backed object storage, attachments, and work products. Sensitive values stay out of prompts unless a scoped run explicitly needs them.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Activity & Events** — Mutating actions, heartbeat state changes, cost events, approvals, comments, and work products are recorded as durable activity so operators can audit what happened and why.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
|
|
||||||
**Company Portability** — Export and import entire organizations — agents, skills, projects, routines, and issues — with secret scrubbing and collision handling. One deployment, many companies, complete data isolation.
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## What Paperclip is not
|
## What Paperclip is not
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -287,14 +177,6 @@ Open source. Self-hosted. No Paperclip account required.
|
|||||||
npx paperclipai onboard --yes
|
npx paperclipai onboard --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx paperclipai onboard --yes --bind lan
|
|
||||||
# or:
|
|
||||||
npx paperclipai onboard --yes --bind tailnet
|
|
||||||
```
|
|
||||||
|
|
||||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||||
|
|
||||||
Or manually:
|
Or manually:
|
||||||
@@ -343,15 +225,11 @@ pnpm dev:once # Full dev without file watching
|
|||||||
pnpm dev:server # Server only
|
pnpm dev:server # Server only
|
||||||
pnpm build # Build all
|
pnpm build # Build all
|
||||||
pnpm typecheck # Type checking
|
pnpm typecheck # Type checking
|
||||||
pnpm test # Cheap default test run (Vitest only)
|
pnpm test:run # Run tests
|
||||||
pnpm test:watch # Vitest watch mode
|
|
||||||
pnpm test:e2e # Playwright browser suite
|
|
||||||
pnpm db:generate # Generate DB migration
|
pnpm db:generate # Generate DB migration
|
||||||
pnpm db:migrate # Apply migrations
|
pnpm db:migrate # Apply migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
|
|
||||||
|
|
||||||
See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
@@ -365,23 +243,14 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
|||||||
- ✅ Skills Manager
|
- ✅ Skills Manager
|
||||||
- ✅ Scheduled Routines
|
- ✅ Scheduled Routines
|
||||||
- ✅ Better Budgeting
|
- ✅ Better Budgeting
|
||||||
- ✅ Agent Reviews and Approvals
|
- ⚪ Artifacts & Deployments
|
||||||
- ✅ Multiple Human Users
|
|
||||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
|
||||||
- ⚪ Artifacts & Work Products
|
|
||||||
- ⚪ Memory / Knowledge
|
|
||||||
- ⚪ Enforced Outcomes
|
|
||||||
- ⚪ MAXIMIZER MODE
|
|
||||||
- ⚪ Deep Planning
|
|
||||||
- ⚪ Work Queues
|
|
||||||
- ⚪ Self-Organization
|
|
||||||
- ⚪ Automatic Organizational Learning
|
|
||||||
- ⚪ CEO Chat
|
- ⚪ CEO Chat
|
||||||
|
- ⚪ MAXIMIZER MODE
|
||||||
|
- ⚪ Multiple Human Users
|
||||||
|
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||||
- ⚪ Cloud deployments
|
- ⚪ Cloud deployments
|
||||||
- ⚪ Desktop App
|
- ⚪ Desktop App
|
||||||
|
|
||||||
This is the short roadmap preview. See the full roadmap in [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## Community & Plugins
|
## Community & Plugins
|
||||||
@@ -394,12 +263,12 @@ Paperclip collects anonymous usage telemetry to help us understand how the produ
|
|||||||
|
|
||||||
Telemetry is **enabled by default** and can be disabled with any of the following:
|
Telemetry is **enabled by default** and can be disabled with any of the following:
|
||||||
|
|
||||||
| Method | How |
|
| Method | How |
|
||||||
| -------------------- | ------------------------------------------------------- |
|
|---|---|
|
||||||
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
|
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
|
||||||
| Standard convention | `DO_NOT_TRACK=1` |
|
| Standard convention | `DO_NOT_TRACK=1` |
|
||||||
| CI environments | Automatically disabled when `CI=true` |
|
| CI environments | Automatically disabled when `CI=true` |
|
||||||
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
|
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -410,7 +279,6 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
|
|||||||
## Community
|
## Community
|
||||||
|
|
||||||
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
|
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
|
||||||
- [Twitter / X](https://x.com/papercliping) — Follow updates and announcements
|
|
||||||
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
|
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
|
||||||
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
|
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
This document expands the roadmap preview in `README.md`.
|
|
||||||
|
|
||||||
Paperclip is still moving quickly. The list below is directional, not promised, and priorities may shift as we learn from users and from operating real AI companies with the product.
|
|
||||||
|
|
||||||
We value community involvement and want to make sure contributor energy goes toward areas where it can land.
|
|
||||||
|
|
||||||
We may accept contributions in the areas below, but if you want to work on roadmap-level core features, please coordinate with us first in Discord (`#dev`) before writing code. Bugs, docs, polish, and tightly scoped improvements are still the easiest contributions to merge.
|
|
||||||
|
|
||||||
If you want to extend Paperclip today, the best path is often the [plugin system](doc/plugins/PLUGIN_SPEC.md). Community reference implementations are also useful feedback even when they are not merged directly into core.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
### ✅ Plugin system
|
|
||||||
|
|
||||||
Paperclip should keep a thin core and rich edges. Plugins are the path for optional capabilities like knowledge bases, custom tracing, queues, doc editors, and other product-specific surfaces that do not need to live in the control plane itself.
|
|
||||||
|
|
||||||
### ✅ Get OpenClaw / claw-style agent employees
|
|
||||||
|
|
||||||
Paperclip should be able to hire and manage real claw-style agent workers, not just a narrow built-in runtime. This is part of the larger "bring your own agent" story and keeps the control plane useful across different agent ecosystems.
|
|
||||||
|
|
||||||
### ✅ companies.sh - import and export entire organizations
|
|
||||||
|
|
||||||
Reusable companies matter. Import/export is the foundation for moving org structures, agent definitions, and reusable company setups between environments and eventually for broader company-template distribution.
|
|
||||||
|
|
||||||
### ✅ Easy AGENTS.md configurations
|
|
||||||
|
|
||||||
Agent setup should feel repo-native and legible. Simple `AGENTS.md`-style configuration lowers the barrier to getting an agent team running and makes it easier for contributors to understand how a company is wired together.
|
|
||||||
|
|
||||||
### ✅ Skills Manager
|
|
||||||
|
|
||||||
Agents need a practical way to discover, install, and use skills without every setup becoming bespoke. The skills layer is part of making Paperclip companies more reusable and easier to operate.
|
|
||||||
|
|
||||||
### ✅ Scheduled Routines
|
|
||||||
|
|
||||||
Recurring work should be native. Routine tasks like reports, reviews, and other periodic work need first-class scheduling so the company keeps operating even when no human is manually kicking work off.
|
|
||||||
|
|
||||||
### ✅ Better Budgeting
|
|
||||||
|
|
||||||
Budgets are a core control-plane feature, not an afterthought. Better budgeting means clearer spend visibility, safer hard stops, and better operator control over how autonomy turns into real cost.
|
|
||||||
|
|
||||||
### ✅ Agent Reviews and Approvals
|
|
||||||
|
|
||||||
Paperclip should support explicit review and approval stages as first-class workflow steps, not just ad hoc comments. That means reviewer routing, approval gates, change requests, and durable audit trails that fit the same task model as the rest of the control plane.
|
|
||||||
|
|
||||||
### ✅ Multiple Human Users
|
|
||||||
|
|
||||||
Paperclip needs a clearer path from solo operator to real human teams. That means shared board access, safer collaboration, and a better model for several humans supervising the same autonomous company.
|
|
||||||
|
|
||||||
### ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
|
||||||
|
|
||||||
We want agents to run in more remote and sandboxed environments while preserving the same Paperclip control-plane model. This makes the system safer, more flexible, and more useful outside a single trusted local machine.
|
|
||||||
|
|
||||||
### ⚪ Artifacts & Work Products
|
|
||||||
|
|
||||||
Paperclip should make outputs first-class. That means generated artifacts, previews, deployable outputs, and the handoff from "agent did work" to "here is the result" should become more visible and easier to operate.
|
|
||||||
|
|
||||||
### ⚪ Memory / Knowledge
|
|
||||||
|
|
||||||
We want a stronger memory and knowledge surface for companies, agents, and projects. That includes durable memory, better recall of prior decisions and context, and a clearer path for knowledge-style capabilities without turning Paperclip into a generic chat app.
|
|
||||||
|
|
||||||
### ⚪ Enforced Outcomes
|
|
||||||
|
|
||||||
Paperclip should get stricter about what counts as finished work. Tasks, approvals, and execution flows should resolve to clear outcomes like merged code, published artifacts, shipped docs, or explicit decisions instead of stopping at vague status updates.
|
|
||||||
|
|
||||||
### ⚪ MAXIMIZER MODE
|
|
||||||
|
|
||||||
This is the direction for higher-autonomy execution: more aggressive delegation, deeper follow-through, and stronger operating loops with clear budgets, visibility, and governance. The point is not hidden autonomy; the point is more output per human supervisor.
|
|
||||||
|
|
||||||
### ⚪ Deep Planning
|
|
||||||
|
|
||||||
Some work needs more than a task description before execution starts. Deeper planning means stronger issue documents, revisionable plans, and clearer review loops for strategy-heavy work before agents begin execution.
|
|
||||||
|
|
||||||
### ⚪ Work Queues
|
|
||||||
|
|
||||||
Paperclip should support queue-style work streams for repeatable inputs like support, triage, review, and backlog intake. That would make it easier to route work continuously without turning every system into a one-off workflow.
|
|
||||||
|
|
||||||
### ⚪ Self-Organization
|
|
||||||
|
|
||||||
As companies grow, agents should be able to propose useful structural changes such as role adjustments, delegation changes, and new recurring routines. The goal is adaptive organizations that still stay within governance and approval boundaries.
|
|
||||||
|
|
||||||
### ⚪ Automatic Organizational Learning
|
|
||||||
|
|
||||||
Paperclip should get better at turning completed work into reusable organizational knowledge. That includes capturing playbooks, recurring fixes, and decision patterns so future work starts from what the company has already learned.
|
|
||||||
|
|
||||||
### ⚪ CEO Chat
|
|
||||||
|
|
||||||
We want a lighter-weight way to talk to leadership agents, but those conversations should still resolve to real work objects like plans, issues, approvals, or decisions. This should improve interaction without changing the core task-and-comments model.
|
|
||||||
|
|
||||||
### ⚪ Cloud deployments
|
|
||||||
|
|
||||||
Local-first remains important, but Paperclip also needs a cleaner shared deployment story. Teams should be able to run the same product in hosted or semi-hosted environments without changing the mental model.
|
|
||||||
|
|
||||||
### ⚪ Desktop App
|
|
||||||
|
|
||||||
A desktop app can make Paperclip feel more accessible and persistent for day-to-day operators. The goal is easier access, better local ergonomics, and a smoother default experience for users who want the control plane always close at hand.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
Please report security vulnerabilities through GitHub's Security Advisory feature:
|
|
||||||
[https://github.com/paperclipai/paperclip/security/advisories/new](https://github.com/paperclipai/paperclip/security/advisories/new)
|
|
||||||
|
|
||||||
Do not open public issues for security vulnerabilities.
|
|
||||||
@@ -6,14 +6,13 @@
|
|||||||
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
||||||
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
||||||
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</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://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
|
||||||
<a href="https://x.com/papercliping"><strong>Twitter</strong></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
|
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
|
||||||
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
|
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
@@ -178,14 +177,6 @@ Open source. Self-hosted. No Paperclip account required.
|
|||||||
npx paperclipai onboard --yes
|
npx paperclipai onboard --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx paperclipai onboard --yes --bind lan
|
|
||||||
# or:
|
|
||||||
npx paperclipai onboard --yes --bind tailnet
|
|
||||||
```
|
|
||||||
|
|
||||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||||
|
|
||||||
Or manually:
|
Or manually:
|
||||||
@@ -234,15 +225,11 @@ pnpm dev:once # Full dev without file watching
|
|||||||
pnpm dev:server # Server only
|
pnpm dev:server # Server only
|
||||||
pnpm build # Build all
|
pnpm build # Build all
|
||||||
pnpm typecheck # Type checking
|
pnpm typecheck # Type checking
|
||||||
pnpm test # Cheap default test run (Vitest only)
|
pnpm test:run # Run tests
|
||||||
pnpm test:watch # Vitest watch mode
|
|
||||||
pnpm test:e2e # Playwright browser suite
|
|
||||||
pnpm db:generate # Generate DB migration
|
pnpm db:generate # Generate DB migration
|
||||||
pnpm db:migrate # Apply migrations
|
pnpm db:migrate # Apply migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
|
|
||||||
|
|
||||||
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
|
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
@@ -259,7 +246,7 @@ See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc
|
|||||||
- ⚪ Artifacts & Deployments
|
- ⚪ Artifacts & Deployments
|
||||||
- ⚪ CEO Chat
|
- ⚪ CEO Chat
|
||||||
- ⚪ MAXIMIZER MODE
|
- ⚪ MAXIMIZER MODE
|
||||||
- ✅ Multiple Human Users
|
- ⚪ Multiple Human Users
|
||||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||||
- ⚪ Cloud deployments
|
- ⚪ Cloud deployments
|
||||||
- ⚪ Desktop App
|
- ⚪ Desktop App
|
||||||
@@ -279,7 +266,6 @@ We welcome contributions. See the [contributing guide](https://github.com/paperc
|
|||||||
## Community
|
## Community
|
||||||
|
|
||||||
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
|
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
|
||||||
- [Twitter / X](https://x.com/papercliping) — Follow updates and announcements
|
|
||||||
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
|
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
|
||||||
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
|
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^0.10.0",
|
"@clack/prompts": "^0.10.0",
|
||||||
"@paperclipai/adapter-acpx-local": "workspace:*",
|
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
@@ -50,7 +48,7 @@
|
|||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
"@paperclipai/server": "workspace:*",
|
"@paperclipai/server": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.38.4",
|
||||||
"dotenv": "^17.0.1",
|
"dotenv": "^17.0.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"embedded-postgres": "^18.1.0-beta.16",
|
"embedded-postgres": "^18.1.0-beta.16",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ function makeCompany(overrides: Partial<Company>): Company {
|
|||||||
issueCounter: 1,
|
issueCounter: 1,
|
||||||
budgetMonthlyCents: 0,
|
budgetMonthlyCents: 0,
|
||||||
spentMonthlyCents: 0,
|
spentMonthlyCents: 0,
|
||||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
|
||||||
requireBoardApprovalForNewAgents: false,
|
requireBoardApprovalForNewAgents: false,
|
||||||
feedbackDataSharingEnabled: false,
|
feedbackDataSharingEnabled: false,
|
||||||
feedbackDataSharingConsentAt: null,
|
feedbackDataSharingConsentAt: null,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile, spawn } from "node:child_process";
|
import { execFile, spawn } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -104,50 +104,20 @@ function writeTestConfig(configPath: string, tempRoot: string, port: number, con
|
|||||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TestPaperclipEnv {
|
function createServerEnv(configPath: string, port: number, connectionString: string) {
|
||||||
configPath: string;
|
|
||||||
paperclipHome: string;
|
|
||||||
instanceId: string;
|
|
||||||
shellHome?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBasePaperclipEnv(options: TestPaperclipEnv) {
|
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
for (const key of Object.keys(env)) {
|
for (const key of Object.keys(env)) {
|
||||||
if (key.startsWith("PAPERCLIP_")) {
|
if (key.startsWith("PAPERCLIP_")) {
|
||||||
delete env[key];
|
delete env[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
env.PAPERCLIP_CONFIG = options.configPath;
|
|
||||||
env.PAPERCLIP_HOME = options.paperclipHome;
|
|
||||||
env.PAPERCLIP_INSTANCE_ID = options.instanceId;
|
|
||||||
env.PAPERCLIP_CONTEXT = path.join(options.paperclipHome, "context.json");
|
|
||||||
env.PAPERCLIP_AUTH_STORE = path.join(options.paperclipHome, "auth.json");
|
|
||||||
if (options.shellHome) {
|
|
||||||
env.HOME = options.shellHome;
|
|
||||||
}
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createServerEnv(
|
|
||||||
configPath: string,
|
|
||||||
port: number,
|
|
||||||
connectionString: string,
|
|
||||||
options: Omit<TestPaperclipEnv, "configPath">,
|
|
||||||
) {
|
|
||||||
const env = createBasePaperclipEnv({
|
|
||||||
configPath,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
delete env.DATABASE_URL;
|
delete env.DATABASE_URL;
|
||||||
delete env.PORT;
|
delete env.PORT;
|
||||||
delete env.HOST;
|
delete env.HOST;
|
||||||
delete env.SERVE_UI;
|
delete env.SERVE_UI;
|
||||||
delete env.HEARTBEAT_SCHEDULER_ENABLED;
|
delete env.HEARTBEAT_SCHEDULER_ENABLED;
|
||||||
|
|
||||||
|
env.PAPERCLIP_CONFIG = configPath;
|
||||||
env.DATABASE_URL = connectionString;
|
env.DATABASE_URL = connectionString;
|
||||||
env.HOST = "127.0.0.1";
|
env.HOST = "127.0.0.1";
|
||||||
env.PORT = String(port);
|
env.PORT = String(port);
|
||||||
@@ -160,8 +130,13 @@ function createServerEnv(
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCliEnv(options: TestPaperclipEnv) {
|
function createCliEnv() {
|
||||||
const env = createBasePaperclipEnv(options);
|
const env = { ...process.env };
|
||||||
|
for (const key of Object.keys(env)) {
|
||||||
|
if (key.startsWith("PAPERCLIP_")) {
|
||||||
|
delete env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
delete env.DATABASE_URL;
|
delete env.DATABASE_URL;
|
||||||
delete env.PORT;
|
delete env.PORT;
|
||||||
delete env.HOST;
|
delete env.HOST;
|
||||||
@@ -208,25 +183,14 @@ async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Pr
|
|||||||
return text ? JSON.parse(text) as T : (null as T);
|
return text ? JSON.parse(text) as T : (null as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCliJson<T>(
|
async function runCliJson<T>(args: string[], opts: { apiBase: string; configPath: string }) {
|
||||||
args: string[],
|
|
||||||
opts: TestPaperclipEnv & { apiBase?: string; includeConfigArg?: boolean },
|
|
||||||
) {
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||||
const cliArgs = ["--silent", "paperclipai", ...args];
|
|
||||||
if (opts.apiBase) {
|
|
||||||
cliArgs.push("--api-base", opts.apiBase);
|
|
||||||
}
|
|
||||||
if (opts.includeConfigArg !== false) {
|
|
||||||
cliArgs.push("--config", opts.configPath);
|
|
||||||
}
|
|
||||||
cliArgs.push("--json");
|
|
||||||
const result = await execFileAsync(
|
const result = await execFileAsync(
|
||||||
"pnpm",
|
"pnpm",
|
||||||
cliArgs,
|
["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"],
|
||||||
{
|
{
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: createCliEnv(opts),
|
env: createCliEnv(),
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -271,9 +235,6 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
let configPath = "";
|
let configPath = "";
|
||||||
let exportDir = "";
|
let exportDir = "";
|
||||||
let apiBase = "";
|
let apiBase = "";
|
||||||
let paperclipHome = "";
|
|
||||||
let cliShellHome = "";
|
|
||||||
let paperclipInstanceId = "";
|
|
||||||
let serverProcess: ServerProcess | null = null;
|
let serverProcess: ServerProcess | null = null;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
@@ -281,11 +242,6 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
|
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
|
||||||
configPath = path.join(tempRoot, "config", "config.json");
|
configPath = path.join(tempRoot, "config", "config.json");
|
||||||
exportDir = path.join(tempRoot, "exported-company");
|
exportDir = path.join(tempRoot, "exported-company");
|
||||||
paperclipHome = path.join(tempRoot, "paperclip-home");
|
|
||||||
cliShellHome = path.join(tempRoot, "shell-home");
|
|
||||||
paperclipInstanceId = "company-cli-e2e";
|
|
||||||
mkdirSync(paperclipHome, { recursive: true });
|
|
||||||
mkdirSync(cliShellHome, { recursive: true });
|
|
||||||
|
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
|
||||||
|
|
||||||
@@ -300,11 +256,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
["paperclipai", "run", "--config", configPath],
|
["paperclipai", "run", "--config", configPath],
|
||||||
{
|
{
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: createServerEnv(configPath, port, tempDb.connectionString, {
|
env: createServerEnv(configPath, port, tempDb.connectionString),
|
||||||
paperclipHome,
|
|
||||||
instanceId: paperclipInstanceId,
|
|
||||||
shellHome: cliShellHome,
|
|
||||||
}),
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -330,41 +282,11 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
it("exports a company package and imports it into new and existing companies", async () => {
|
it("exports a company package and imports it into new and existing companies", async () => {
|
||||||
expect(serverProcess).not.toBeNull();
|
expect(serverProcess).not.toBeNull();
|
||||||
|
|
||||||
const cliContext = await runCliJson<{
|
|
||||||
contextPath: string;
|
|
||||||
profileName: string;
|
|
||||||
profile: { apiBase?: string };
|
|
||||||
}>(
|
|
||||||
["context", "set", "--profile", "isolation-check", "--api-base", "https://example.test"],
|
|
||||||
{
|
|
||||||
configPath,
|
|
||||||
paperclipHome,
|
|
||||||
instanceId: paperclipInstanceId,
|
|
||||||
shellHome: cliShellHome,
|
|
||||||
includeConfigArg: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const expectedContextPath = path.join(paperclipHome, "context.json");
|
|
||||||
const leakedContextPath = path.join(cliShellHome, ".paperclip", "context.json");
|
|
||||||
expect(cliContext.contextPath).toBe(expectedContextPath);
|
|
||||||
expect(cliContext.profileName).toBe("isolation-check");
|
|
||||||
expect(cliContext.profile.apiBase).toBe("https://example.test");
|
|
||||||
expect(existsSync(expectedContextPath)).toBe(true);
|
|
||||||
expect(existsSync(leakedContextPath)).toBe(false);
|
|
||||||
rmSync(expectedContextPath, { force: true });
|
|
||||||
expect(existsSync(expectedContextPath)).toBe(false);
|
|
||||||
|
|
||||||
const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
|
const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
|
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
|
||||||
});
|
});
|
||||||
await api(apiBase, `/api/companies/${sourceCompany.id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ requireBoardApprovalForNewAgents: false }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceAgent = await api<{ id: string; name: string }>(
|
const sourceAgent = await api<{ id: string; name: string }>(
|
||||||
apiBase,
|
apiBase,
|
||||||
@@ -376,11 +298,8 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
name: "Export Engineer",
|
name: "Export Engineer",
|
||||||
role: "engineer",
|
role: "engineer",
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
adapterConfig: {},
|
adapterConfig: {
|
||||||
instructionsBundle: {
|
promptTemplate: "You verify company portability.",
|
||||||
files: {
|
|
||||||
"AGENTS.md": "You verify company portability.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -431,13 +350,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
"--include",
|
"--include",
|
||||||
"company,agents,projects,issues",
|
"company,agents,projects,issues",
|
||||||
],
|
],
|
||||||
{
|
{ apiBase, configPath },
|
||||||
apiBase,
|
|
||||||
configPath,
|
|
||||||
paperclipHome,
|
|
||||||
instanceId: paperclipInstanceId,
|
|
||||||
shellHome: cliShellHome,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(exportResult.ok).toBe(true);
|
expect(exportResult.ok).toBe(true);
|
||||||
@@ -461,13 +374,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
"company,agents,projects,issues",
|
"company,agents,projects,issues",
|
||||||
"--yes",
|
"--yes",
|
||||||
],
|
],
|
||||||
{
|
{ apiBase, configPath },
|
||||||
apiBase,
|
|
||||||
configPath,
|
|
||||||
paperclipHome,
|
|
||||||
instanceId: paperclipInstanceId,
|
|
||||||
shellHome: cliShellHome,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(importedNew.company.action).toBe("created");
|
expect(importedNew.company.action).toBe("created");
|
||||||
@@ -486,11 +393,10 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
apiBase,
|
apiBase,
|
||||||
`/api/companies/${importedNew.company.id}/issues`,
|
`/api/companies/${importedNew.company.id}/issues`,
|
||||||
);
|
);
|
||||||
const importedMatchingIssues = importedIssues.filter((issue) => issue.title === sourceIssue.title);
|
|
||||||
|
|
||||||
expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
|
expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
|
||||||
expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
|
expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
|
||||||
expect(importedMatchingIssues).toHaveLength(1);
|
expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title);
|
||||||
|
|
||||||
const previewExisting = await runCliJson<{
|
const previewExisting = await runCliJson<{
|
||||||
errors: string[];
|
errors: string[];
|
||||||
@@ -515,13 +421,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
"rename",
|
"rename",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
],
|
],
|
||||||
{
|
{ apiBase, configPath },
|
||||||
apiBase,
|
|
||||||
configPath,
|
|
||||||
paperclipHome,
|
|
||||||
instanceId: paperclipInstanceId,
|
|
||||||
shellHome: cliShellHome,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(previewExisting.errors).toEqual([]);
|
expect(previewExisting.errors).toEqual([]);
|
||||||
@@ -548,13 +448,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
"rename",
|
"rename",
|
||||||
"--yes",
|
"--yes",
|
||||||
],
|
],
|
||||||
{
|
{ apiBase, configPath },
|
||||||
apiBase,
|
|
||||||
configPath,
|
|
||||||
paperclipHome,
|
|
||||||
instanceId: paperclipInstanceId,
|
|
||||||
shellHome: cliShellHome,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(importedExisting.company.action).toBe("unchanged");
|
expect(importedExisting.company.action).toBe("unchanged");
|
||||||
@@ -572,13 +466,11 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
apiBase,
|
apiBase,
|
||||||
`/api/companies/${importedNew.company.id}/issues`,
|
`/api/companies/${importedNew.company.id}/issues`,
|
||||||
);
|
);
|
||||||
const twiceImportedMatchingIssues = twiceImportedIssues.filter((issue) => issue.title === sourceIssue.title);
|
|
||||||
|
|
||||||
expect(twiceImportedAgents).toHaveLength(2);
|
expect(twiceImportedAgents).toHaveLength(2);
|
||||||
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
|
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
|
||||||
expect(twiceImportedProjects).toHaveLength(2);
|
expect(twiceImportedProjects).toHaveLength(2);
|
||||||
expect(twiceImportedMatchingIssues).toHaveLength(2);
|
expect(twiceImportedIssues).toHaveLength(2);
|
||||||
expect(new Set(twiceImportedMatchingIssues.map((issue) => issue.identifier)).size).toBe(2);
|
|
||||||
|
|
||||||
const zipPath = path.join(tempRoot, "exported-company.zip");
|
const zipPath = path.join(tempRoot, "exported-company.zip");
|
||||||
const portableFiles: Record<string, string> = {};
|
const portableFiles: Record<string, string> = {};
|
||||||
@@ -601,16 +493,10 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
|||||||
"company,agents,projects,issues",
|
"company,agents,projects,issues",
|
||||||
"--yes",
|
"--yes",
|
||||||
],
|
],
|
||||||
{
|
{ apiBase, configPath },
|
||||||
apiBase,
|
|
||||||
configPath,
|
|
||||||
paperclipHome,
|
|
||||||
instanceId: paperclipInstanceId,
|
|
||||||
shellHome: cliShellHome,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(importedFromZip.company.action).toBe("created");
|
expect(importedFromZip.company.action).toBe("created");
|
||||||
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
|
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
|
||||||
}, 90_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ describe("renderCompanyImportPreview", () => {
|
|||||||
path: "COMPANY.md",
|
path: "COMPANY.md",
|
||||||
name: "Source Co",
|
name: "Source Co",
|
||||||
description: null,
|
description: null,
|
||||||
attachmentMaxBytes: null,
|
|
||||||
brandColor: null,
|
brandColor: null,
|
||||||
logoPath: null,
|
logoPath: null,
|
||||||
requireBoardApprovalForNewAgents: false,
|
requireBoardApprovalForNewAgents: false,
|
||||||
@@ -244,7 +243,6 @@ describe("renderCompanyImportPreview", () => {
|
|||||||
billingCode: null,
|
billingCode: null,
|
||||||
executionWorkspaceSettings: null,
|
executionWorkspaceSettings: null,
|
||||||
assigneeAdapterOverrides: null,
|
assigneeAdapterOverrides: null,
|
||||||
comments: [],
|
|
||||||
metadata: null,
|
metadata: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -377,7 +375,6 @@ describe("import selection catalog", () => {
|
|||||||
path: "COMPANY.md",
|
path: "COMPANY.md",
|
||||||
name: "Source Co",
|
name: "Source Co",
|
||||||
description: null,
|
description: null,
|
||||||
attachmentMaxBytes: null,
|
|
||||||
brandColor: null,
|
brandColor: null,
|
||||||
logoPath: "images/company-logo.png",
|
logoPath: "images/company-logo.png",
|
||||||
requireBoardApprovalForNewAgents: false,
|
requireBoardApprovalForNewAgents: false,
|
||||||
@@ -461,7 +458,6 @@ describe("import selection catalog", () => {
|
|||||||
billingCode: null,
|
billingCode: null,
|
||||||
executionWorkspaceSettings: null,
|
executionWorkspaceSettings: null,
|
||||||
assigneeAdapterOverrides: null,
|
assigneeAdapterOverrides: null,
|
||||||
comments: [],
|
|
||||||
metadata: null,
|
metadata: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { collectEnvLabDoctorStatus, resolveEnvLabSshStatePath } from "../commands/env-lab.js";
|
|
||||||
|
|
||||||
describe("env-lab command", () => {
|
|
||||||
it("resolves the default SSH fixture state path under the instance root", () => {
|
|
||||||
const statePath = resolveEnvLabSshStatePath("fixture-test");
|
|
||||||
|
|
||||||
expect(statePath).toContain(
|
|
||||||
path.join("instances", "fixture-test", "env-lab", "ssh-fixture", "state.json"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports doctor status for an instance without a running fixture", async () => {
|
|
||||||
const status = await collectEnvLabDoctorStatus({ instance: "fixture-test-missing" });
|
|
||||||
|
|
||||||
expect(status.statePath).toContain(
|
|
||||||
path.join("instances", "fixture-test-missing", "env-lab", "ssh-fixture", "state.json"),
|
|
||||||
);
|
|
||||||
expect(typeof status.ssh.supported).toBe("boolean");
|
|
||||||
expect(status.ssh.running).toBe(false);
|
|
||||||
expect(status.ssh.environment).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
@@ -17,14 +16,13 @@ describe("home path resolution", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to ~/.paperclip and default instance", () => {
|
it("defaults to ~/.paperclip and default instance", () => {
|
||||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-"));
|
delete process.env.PAPERCLIP_HOME;
|
||||||
process.env.PAPERCLIP_HOME = home;
|
|
||||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||||
|
|
||||||
const paths = describeLocalInstancePaths();
|
const paths = describeLocalInstancePaths();
|
||||||
expect(paths.homeDir).toBe(home);
|
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
|
||||||
expect(paths.instanceId).toBe("default");
|
expect(paths.instanceId).toBe("default");
|
||||||
expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "config.json"));
|
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
||||||
@@ -36,7 +34,7 @@ describe("home path resolution", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid instance ids", () => {
|
it("rejects invalid instance ids", () => {
|
||||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid PAPERCLIP_INSTANCE_ID/);
|
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expands ~ prefixes", () => {
|
it("expands ~ prefixes", () => {
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
|
|
||||||
import { buildPresetServerConfig } from "../config/server-bind.js";
|
|
||||||
|
|
||||||
describe("network bind helpers", () => {
|
|
||||||
it("rejects non-loopback bind modes in local_trusted", () => {
|
|
||||||
expect(
|
|
||||||
validateConfiguredBindMode({
|
|
||||||
deploymentMode: "local_trusted",
|
|
||||||
deploymentExposure: "private",
|
|
||||||
bind: "lan",
|
|
||||||
host: "0.0.0.0",
|
|
||||||
}),
|
|
||||||
).toContain("local_trusted requires server.bind=loopback");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves tailnet bind using the detected tailscale address", () => {
|
|
||||||
const resolved = resolveRuntimeBind({
|
|
||||||
bind: "tailnet",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
tailnetBindHost: "100.64.0.8",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolved.errors).toEqual([]);
|
|
||||||
expect(resolved.host).toBe("100.64.0.8");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires a custom bind host when bind=custom", () => {
|
|
||||||
const resolved = resolveRuntimeBind({
|
|
||||||
bind: "custom",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores the detected tailscale address for tailnet presets", () => {
|
|
||||||
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
|
||||||
|
|
||||||
const preset = buildPresetServerConfig("tailnet", {
|
|
||||||
port: 3100,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(preset.server.host).toBe("100.64.0.8");
|
|
||||||
|
|
||||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to loopback when no tailscale address is available for tailnet presets", () => {
|
|
||||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
|
||||||
|
|
||||||
const preset = buildPresetServerConfig("tailnet", {
|
|
||||||
port: 3100,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(preset.server.host).toBe("127.0.0.1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,6 @@ import { onboard } from "../commands/onboard.js";
|
|||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
const ORIGINAL_CWD = process.cwd();
|
|
||||||
|
|
||||||
function createExistingConfigFixture() {
|
function createExistingConfigFixture() {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
|
||||||
@@ -75,29 +74,16 @@ function createExistingConfigFixture() {
|
|||||||
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
|
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFreshConfigPath() {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-fresh-"));
|
|
||||||
return path.join(root, ".paperclip", "config.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("onboard", () => {
|
describe("onboard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = { ...ORIGINAL_ENV };
|
process.env = { ...ORIGINAL_ENV };
|
||||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||||
delete process.env.PAPERCLIP_HOME;
|
|
||||||
delete process.env.PAPERCLIP_CONFIG;
|
|
||||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
||||||
delete process.env.PAPERCLIP_BIND;
|
|
||||||
delete process.env.PAPERCLIP_BIND_HOST;
|
|
||||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
|
||||||
delete process.env.HOST;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = { ...ORIGINAL_ENV };
|
process.env = { ...ORIGINAL_ENV };
|
||||||
process.chdir(ORIGINAL_CWD);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves an existing config when rerun without flags", async () => {
|
it("preserves an existing config when rerun without flags", async () => {
|
||||||
@@ -119,78 +105,4 @@ describe("onboard", () => {
|
|||||||
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
||||||
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
|
|
||||||
const configPath = createFreshConfigPath();
|
|
||||||
process.env.HOST = "0.0.0.0";
|
|
||||||
process.env.PAPERCLIP_BIND = "lan";
|
|
||||||
|
|
||||||
await onboard({ config: configPath, yes: true, invokedByRun: true });
|
|
||||||
|
|
||||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
|
||||||
expect(raw.server.deploymentMode).toBe("local_trusted");
|
|
||||||
expect(raw.server.exposure).toBe("private");
|
|
||||||
expect(raw.server.bind).toBe("loopback");
|
|
||||||
expect(raw.server.host).toBe("127.0.0.1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates instance-root config and data paths for a fresh PAPERCLIP_HOME", async () => {
|
|
||||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-home-"));
|
|
||||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-cwd-"));
|
|
||||||
process.chdir(cwd);
|
|
||||||
process.env.PAPERCLIP_HOME = home;
|
|
||||||
|
|
||||||
await onboard({ yes: true, invokedByRun: true });
|
|
||||||
|
|
||||||
const instanceRoot = path.join(home, "instances", "default");
|
|
||||||
const configPath = path.join(instanceRoot, "config.json");
|
|
||||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
|
||||||
|
|
||||||
expect(raw.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
|
||||||
expect(raw.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
|
|
||||||
expect(raw.logging.logDir).toBe(path.join(instanceRoot, "logs"));
|
|
||||||
expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
|
||||||
expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
|
||||||
expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true);
|
|
||||||
expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports authenticated/private quickstart bind presets", async () => {
|
|
||||||
const configPath = createFreshConfigPath();
|
|
||||||
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
|
||||||
|
|
||||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
|
||||||
|
|
||||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
|
||||||
expect(raw.server.deploymentMode).toBe("authenticated");
|
|
||||||
expect(raw.server.exposure).toBe("private");
|
|
||||||
expect(raw.server.bind).toBe("tailnet");
|
|
||||||
expect(raw.server.host).toBe("100.64.0.8");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps tailnet quickstart on loopback until tailscale is available", async () => {
|
|
||||||
const configPath = createFreshConfigPath();
|
|
||||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
|
||||||
|
|
||||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
|
||||||
|
|
||||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
|
||||||
expect(raw.server.deploymentMode).toBe("authenticated");
|
|
||||||
expect(raw.server.exposure).toBe("private");
|
|
||||||
expect(raw.server.bind).toBe("tailnet");
|
|
||||||
expect(raw.server.host).toBe("127.0.0.1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores deployment env overrides during --yes quickstart", async () => {
|
|
||||||
const configPath = createFreshConfigPath();
|
|
||||||
process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
|
|
||||||
|
|
||||||
await onboard({ config: configPath, yes: true, invokedByRun: true });
|
|
||||||
|
|
||||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
|
||||||
expect(raw.server.deploymentMode).toBe("local_trusted");
|
|
||||||
expect(raw.server.exposure).toBe("private");
|
|
||||||
expect(raw.server.bind).toBe("loopback");
|
|
||||||
expect(raw.server.host).toBe("127.0.0.1");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { Command } from "commander";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../packages/plugins/create-paperclip-plugin/src/index.js", async () => {
|
|
||||||
const actual =
|
|
||||||
await vi.importActual<typeof import("../../../packages/plugins/create-paperclip-plugin/src/index.js")>(
|
|
||||||
"../../../packages/plugins/create-paperclip-plugin/src/index.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
scaffoldPluginProject: mocks.scaffoldPluginProject,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildPluginInstallRequest,
|
|
||||||
buildPluginInitNextCommands,
|
|
||||||
buildPluginInitScaffoldOptions,
|
|
||||||
registerPluginCommands,
|
|
||||||
} from "../commands/client/plugin.js";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
function makeTempDir(): string {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
const dir = tempDirs.pop();
|
|
||||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("plugin init", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.scaffoldPluginProject.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps package name and flags to scaffolder options", () => {
|
|
||||||
const cwd = path.resolve("/tmp/paperclip-cli-test");
|
|
||||||
const options = buildPluginInitScaffoldOptions(
|
|
||||||
"@acme/plugin-linear",
|
|
||||||
{
|
|
||||||
output: "plugins",
|
|
||||||
template: "connector",
|
|
||||||
category: "automation",
|
|
||||||
displayName: "Linear Bridge",
|
|
||||||
description: "Syncs Linear issues",
|
|
||||||
author: "Acme",
|
|
||||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
|
||||||
},
|
|
||||||
cwd,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(options).toEqual({
|
|
||||||
pluginName: "@acme/plugin-linear",
|
|
||||||
outputDir: path.resolve(cwd, "plugins", "plugin-linear"),
|
|
||||||
template: "connector",
|
|
||||||
category: "automation",
|
|
||||||
displayName: "Linear Bridge",
|
|
||||||
description: "Syncs Linear issues",
|
|
||||||
author: "Acme",
|
|
||||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds exact next commands using the scaffold path", () => {
|
|
||||||
expect(buildPluginInitNextCommands("/tmp/acme plugin")).toEqual([
|
|
||||||
"cd '/tmp/acme plugin'",
|
|
||||||
"pnpm install",
|
|
||||||
"pnpm dev",
|
|
||||||
"paperclipai plugin install '/tmp/acme plugin'",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers the CLI wrapper and invokes the existing scaffolder", async () => {
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
|
||||||
registerPluginCommands(program);
|
|
||||||
|
|
||||||
await program.parseAsync(
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"init",
|
|
||||||
"demo-plugin",
|
|
||||||
"--output",
|
|
||||||
"/tmp/paperclip-init-output",
|
|
||||||
"--template",
|
|
||||||
"workspace",
|
|
||||||
"--category",
|
|
||||||
"workspace",
|
|
||||||
"--display-name",
|
|
||||||
"Demo Plugin",
|
|
||||||
"--description",
|
|
||||||
"Demo description",
|
|
||||||
"--author",
|
|
||||||
"Paperclip",
|
|
||||||
"--sdk-path",
|
|
||||||
"/repo/packages/plugins/sdk",
|
|
||||||
],
|
|
||||||
{ from: "user" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledWith({
|
|
||||||
pluginName: "demo-plugin",
|
|
||||||
outputDir: path.resolve("/tmp/paperclip-init-output", "demo-plugin"),
|
|
||||||
template: "workspace",
|
|
||||||
category: "workspace",
|
|
||||||
displayName: "Demo Plugin",
|
|
||||||
description: "Demo description",
|
|
||||||
author: "Paperclip",
|
|
||||||
sdkPath: "/repo/packages/plugins/sdk",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("plugin install", () => {
|
|
||||||
it("resolves an existing relative local path to an absolute local install request", () => {
|
|
||||||
const cwd = makeTempDir();
|
|
||||||
const pluginDir = path.join(cwd, "demo-plugin");
|
|
||||||
fs.mkdirSync(pluginDir);
|
|
||||||
|
|
||||||
expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({
|
|
||||||
packageName: pluginDir,
|
|
||||||
version: undefined,
|
|
||||||
isLocalPath: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps an absolute local path absolute and marks it as local", () => {
|
|
||||||
const pluginDir = path.join(makeTempDir(), "demo-plugin");
|
|
||||||
fs.mkdirSync(pluginDir);
|
|
||||||
|
|
||||||
expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({
|
|
||||||
packageName: pluginDir,
|
|
||||||
version: undefined,
|
|
||||||
isLocalPath: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves npm package installs when no local path exists", () => {
|
|
||||||
expect(
|
|
||||||
buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, {
|
|
||||||
cwd: makeTempDir(),
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
packageName: "@acme/plugin-linear",
|
|
||||||
version: "1.2.3",
|
|
||||||
isLocalPath: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import type { Agent, CompanySecret } from "@paperclipai/shared";
|
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
|
||||||
import { secretsCheck } from "../checks/secrets-check.js";
|
|
||||||
import {
|
|
||||||
buildInlineMigrationSecretName,
|
|
||||||
buildMigratedAgentEnv,
|
|
||||||
collectInlineSecretMigrationCandidates,
|
|
||||||
parseSecretsInclude,
|
|
||||||
toPlainEnvValue,
|
|
||||||
} from "../commands/client/secrets.js";
|
|
||||||
|
|
||||||
function agent(partial: Partial<Agent>): Agent {
|
|
||||||
return {
|
|
||||||
id: "agent-12345678",
|
|
||||||
companyId: "company-1",
|
|
||||||
name: "Coder",
|
|
||||||
urlKey: "coder",
|
|
||||||
role: "engineer",
|
|
||||||
title: null,
|
|
||||||
icon: null,
|
|
||||||
status: "idle",
|
|
||||||
reportsTo: null,
|
|
||||||
capabilities: null,
|
|
||||||
adapterType: "codex_local",
|
|
||||||
adapterConfig: {},
|
|
||||||
runtimeConfig: {},
|
|
||||||
budgetMonthlyCents: 0,
|
|
||||||
spentMonthlyCents: 0,
|
|
||||||
pauseReason: null,
|
|
||||||
pausedAt: null,
|
|
||||||
permissions: {
|
|
||||||
canCreateAgents: false,
|
|
||||||
},
|
|
||||||
lastHeartbeatAt: null,
|
|
||||||
metadata: null,
|
|
||||||
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
|
||||||
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
|
||||||
...partial,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function secret(partial: Partial<CompanySecret>): CompanySecret {
|
|
||||||
return {
|
|
||||||
id: "secret-1",
|
|
||||||
companyId: "company-1",
|
|
||||||
key: "agent_agent-12_anthropic_api_key",
|
|
||||||
name: "agent_agent-12_anthropic_api_key",
|
|
||||||
provider: "local_encrypted",
|
|
||||||
status: "active",
|
|
||||||
managedMode: "paperclip_managed",
|
|
||||||
externalRef: null,
|
|
||||||
providerConfigId: null,
|
|
||||||
providerMetadata: null,
|
|
||||||
latestVersion: 1,
|
|
||||||
description: null,
|
|
||||||
lastResolvedAt: null,
|
|
||||||
lastRotatedAt: null,
|
|
||||||
deletedAt: null,
|
|
||||||
createdByAgentId: null,
|
|
||||||
createdByUserId: null,
|
|
||||||
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
|
||||||
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
|
||||||
...partial,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function configWithSecretsProvider(provider: PaperclipConfig["secrets"]["provider"]): PaperclipConfig {
|
|
||||||
return {
|
|
||||||
$meta: {
|
|
||||||
version: 1,
|
|
||||||
updatedAt: "2026-05-02T00:00:00.000Z",
|
|
||||||
source: "configure",
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: "/tmp/paperclip/db",
|
|
||||||
embeddedPostgresPort: 55432,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: "/tmp/paperclip/backups",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
mode: "file",
|
|
||||||
logDir: "/tmp/paperclip/logs",
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "local_trusted",
|
|
||||||
exposure: "private",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3100,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
baseUrlMode: "auto",
|
|
||||||
disableSignUp: false,
|
|
||||||
},
|
|
||||||
telemetry: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
provider: "local_disk",
|
|
||||||
localDisk: {
|
|
||||||
baseDir: "/tmp/paperclip/storage",
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
bucket: "paperclip",
|
|
||||||
region: "us-east-1",
|
|
||||||
prefix: "",
|
|
||||||
forcePathStyle: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: {
|
|
||||||
provider,
|
|
||||||
strictMode: true,
|
|
||||||
localEncrypted: {
|
|
||||||
keyFilePath: "/tmp/paperclip/secrets/master.key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("secrets CLI helpers", () => {
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
|
|
||||||
delete process.env.AWS_REGION;
|
|
||||||
delete process.env.AWS_DEFAULT_REGION;
|
|
||||||
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
|
||||||
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses declaration include filters", () => {
|
|
||||||
expect(parseSecretsInclude("agents,projects,tasks")).toEqual({
|
|
||||||
company: false,
|
|
||||||
agents: true,
|
|
||||||
projects: true,
|
|
||||||
issues: true,
|
|
||||||
skills: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects inline sensitive env values that need migration", () => {
|
|
||||||
const rows = collectInlineSecretMigrationCandidates(
|
|
||||||
[
|
|
||||||
agent({
|
|
||||||
id: "agent-12345678",
|
|
||||||
adapterConfig: {
|
|
||||||
env: {
|
|
||||||
ANTHROPIC_API_KEY: "sk-ant-test",
|
|
||||||
GH_TOKEN: {
|
|
||||||
type: "plain",
|
|
||||||
value: "ghp-test",
|
|
||||||
},
|
|
||||||
PATH: {
|
|
||||||
type: "plain",
|
|
||||||
value: "/usr/bin",
|
|
||||||
},
|
|
||||||
OPENAI_API_KEY: {
|
|
||||||
type: "secret_ref",
|
|
||||||
secretId: "secret-existing",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
secret({
|
|
||||||
id: "secret-gh-token",
|
|
||||||
name: buildInlineMigrationSecretName("agent-12345678", "GH_TOKEN"),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(rows).toEqual([
|
|
||||||
{
|
|
||||||
agentId: "agent-12345678",
|
|
||||||
agentName: "Coder",
|
|
||||||
envKey: "ANTHROPIC_API_KEY",
|
|
||||||
secretName: "agent_agent-12_anthropic_api_key",
|
|
||||||
existingSecretId: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
agentId: "agent-12345678",
|
|
||||||
agentName: "Coder",
|
|
||||||
envKey: "GH_TOKEN",
|
|
||||||
secretName: "agent_agent-12_gh_token",
|
|
||||||
existingSecretId: "secret-gh-token",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds migrated env bindings without preserving secret values", () => {
|
|
||||||
const next = buildMigratedAgentEnv(
|
|
||||||
{
|
|
||||||
ANTHROPIC_API_KEY: "sk-ant-test",
|
|
||||||
NODE_ENV: {
|
|
||||||
type: "plain",
|
|
||||||
value: "development",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
new Map([["ANTHROPIC_API_KEY", "secret-1"]]),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(next).toEqual({
|
|
||||||
ANTHROPIC_API_KEY: {
|
|
||||||
type: "secret_ref",
|
|
||||||
secretId: "secret-1",
|
|
||||||
version: "latest",
|
|
||||||
},
|
|
||||||
NODE_ENV: {
|
|
||||||
type: "plain",
|
|
||||||
value: "development",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(next)).not.toContain("sk-ant-test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads only explicit plain env values", () => {
|
|
||||||
expect(toPlainEnvValue("plain-value")).toBe("plain-value");
|
|
||||||
expect(toPlainEnvValue({ type: "plain", value: "wrapped" })).toBe("wrapped");
|
|
||||||
expect(toPlainEnvValue({ type: "secret_ref", secretId: "secret-1" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the AWS bootstrap config required by doctor", () => {
|
|
||||||
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
|
||||||
|
|
||||||
expect(result.status).toBe("fail");
|
|
||||||
expect(result.message).toContain("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
|
||||||
expect(result.repairHint).toContain("AWS SDK default credential chain");
|
|
||||||
expect(result.repairHint).toContain("Do not store AWS root credentials");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes AWS doctor checks when non-secret provider config is present", () => {
|
|
||||||
process.env.PAPERCLIP_SECRETS_AWS_REGION = "us-east-1";
|
|
||||||
process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID = "prod-us-1";
|
|
||||||
process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID =
|
|
||||||
"arn:aws:kms:us-east-1:123456789012:key/test";
|
|
||||||
process.env.AWS_PROFILE = "paperclip-prod";
|
|
||||||
|
|
||||||
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
|
||||||
|
|
||||||
expect(result.status).toBe("pass");
|
|
||||||
expect(result.message).toContain("prod-us-1");
|
|
||||||
expect(result.message).toContain("AWS_PROFILE/shared config");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,25 +2,10 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
|
||||||
agents,
|
|
||||||
authUsers,
|
|
||||||
companies,
|
|
||||||
createDb,
|
|
||||||
issueComments,
|
|
||||||
issues,
|
|
||||||
projects,
|
|
||||||
routines,
|
|
||||||
routineTriggers,
|
|
||||||
} from "@paperclipai/db";
|
|
||||||
import {
|
import {
|
||||||
copyGitHooksToWorktreeGitDir,
|
copyGitHooksToWorktreeGitDir,
|
||||||
copySeededSecretsKey,
|
copySeededSecretsKey,
|
||||||
pauseSeededScheduledRoutines,
|
|
||||||
quarantineSeededWorktreeExecutionState,
|
|
||||||
readSourceAttachmentBody,
|
readSourceAttachmentBody,
|
||||||
rebindWorkspaceCwd,
|
rebindWorkspaceCwd,
|
||||||
resolveSourceConfigPath,
|
resolveSourceConfigPath,
|
||||||
@@ -28,7 +13,6 @@ import {
|
|||||||
resolveWorktreeReseedTargetPaths,
|
resolveWorktreeReseedTargetPaths,
|
||||||
resolveGitWorktreeAddArgs,
|
resolveGitWorktreeAddArgs,
|
||||||
resolveWorktreeMakeTargetPath,
|
resolveWorktreeMakeTargetPath,
|
||||||
worktreeRepairCommand,
|
|
||||||
worktreeInitCommand,
|
worktreeInitCommand,
|
||||||
worktreeMakeCommand,
|
worktreeMakeCommand,
|
||||||
worktreeReseedCommand,
|
worktreeReseedCommand,
|
||||||
@@ -44,22 +28,9 @@ import {
|
|||||||
sanitizeWorktreeInstanceId,
|
sanitizeWorktreeInstanceId,
|
||||||
} from "../commands/worktree-lib.js";
|
} from "../commands/worktree-lib.js";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
} from "./helpers/embedded-postgres.js";
|
|
||||||
|
|
||||||
const ORIGINAL_CWD = process.cwd();
|
const ORIGINAL_CWD = process.cwd();
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
||||||
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
|
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
|
||||||
console.warn(
|
|
||||||
`Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.chdir(ORIGINAL_CWD);
|
process.chdir(ORIGINAL_CWD);
|
||||||
@@ -190,9 +161,8 @@ describe("worktree helpers", () => {
|
|||||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rewrites auth URLs only when they already include a port", () => {
|
it("rewrites loopback auth URLs to the new port only", () => {
|
||||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||||
expect(rewriteLocalUrlPort("http://my-host.ts.net:3100", 3110)).toBe("http://my-host.ts.net:3110/");
|
|
||||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,138 +257,6 @@ describe("worktree helpers", () => {
|
|||||||
expect(full.nullifyColumns).toEqual({});
|
expect(full.nullifyColumns).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
itEmbeddedPostgres("quarantines copied live execution state in seeded worktree databases", async () => {
|
|
||||||
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-quarantine-");
|
|
||||||
const db = createDb(tempDb.connectionString);
|
|
||||||
const companyId = randomUUID();
|
|
||||||
const agentId = randomUUID();
|
|
||||||
const idleAgentId = randomUUID();
|
|
||||||
const inProgressIssueId = randomUUID();
|
|
||||||
const todoIssueId = randomUUID();
|
|
||||||
const reviewIssueId = randomUUID();
|
|
||||||
const userIssueId = randomUUID();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(companies).values({
|
|
||||||
id: companyId,
|
|
||||||
name: "Paperclip",
|
|
||||||
issuePrefix: "WTQ",
|
|
||||||
requireBoardApprovalForNewAgents: false,
|
|
||||||
});
|
|
||||||
await db.insert(agents).values([
|
|
||||||
{
|
|
||||||
id: agentId,
|
|
||||||
companyId,
|
|
||||||
name: "CodexCoder",
|
|
||||||
role: "engineer",
|
|
||||||
status: "running",
|
|
||||||
adapterType: "codex_local",
|
|
||||||
adapterConfig: {},
|
|
||||||
runtimeConfig: {
|
|
||||||
heartbeat: { enabled: true, intervalSec: 60 },
|
|
||||||
wakeOnDemand: true,
|
|
||||||
},
|
|
||||||
permissions: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: idleAgentId,
|
|
||||||
companyId,
|
|
||||||
name: "Reviewer",
|
|
||||||
role: "reviewer",
|
|
||||||
status: "idle",
|
|
||||||
adapterType: "codex_local",
|
|
||||||
adapterConfig: {},
|
|
||||||
runtimeConfig: { heartbeat: { enabled: false, intervalSec: 300 } },
|
|
||||||
permissions: {},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await db.insert(issues).values([
|
|
||||||
{
|
|
||||||
id: inProgressIssueId,
|
|
||||||
companyId,
|
|
||||||
title: "Copied in-flight issue",
|
|
||||||
status: "in_progress",
|
|
||||||
priority: "medium",
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
issueNumber: 1,
|
|
||||||
identifier: "WTQ-1",
|
|
||||||
executionAgentNameKey: "codexcoder",
|
|
||||||
executionLockedAt: new Date("2026-04-18T00:00:00.000Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: todoIssueId,
|
|
||||||
companyId,
|
|
||||||
title: "Copied assigned todo issue",
|
|
||||||
status: "todo",
|
|
||||||
priority: "medium",
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
issueNumber: 2,
|
|
||||||
identifier: "WTQ-2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: reviewIssueId,
|
|
||||||
companyId,
|
|
||||||
title: "Copied assigned review issue",
|
|
||||||
status: "in_review",
|
|
||||||
priority: "medium",
|
|
||||||
assigneeAgentId: idleAgentId,
|
|
||||||
issueNumber: 3,
|
|
||||||
identifier: "WTQ-3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: userIssueId,
|
|
||||||
companyId,
|
|
||||||
title: "Copied user issue",
|
|
||||||
status: "todo",
|
|
||||||
priority: "medium",
|
|
||||||
assigneeUserId: "user-1",
|
|
||||||
issueNumber: 4,
|
|
||||||
identifier: "WTQ-4",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(quarantineSeededWorktreeExecutionState(tempDb.connectionString)).resolves.toEqual({
|
|
||||||
disabledTimerHeartbeats: 1,
|
|
||||||
resetRunningAgents: 1,
|
|
||||||
quarantinedInProgressIssues: 1,
|
|
||||||
unassignedTodoIssues: 1,
|
|
||||||
unassignedReviewIssues: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [quarantinedAgent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
||||||
expect(quarantinedAgent?.status).toBe("idle");
|
|
||||||
expect(quarantinedAgent?.runtimeConfig).toMatchObject({
|
|
||||||
heartbeat: { enabled: false, intervalSec: 60 },
|
|
||||||
wakeOnDemand: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [inProgressIssue] = await db.select().from(issues).where(eq(issues.id, inProgressIssueId));
|
|
||||||
expect(inProgressIssue?.status).toBe("blocked");
|
|
||||||
expect(inProgressIssue?.assigneeAgentId).toBeNull();
|
|
||||||
expect(inProgressIssue?.executionAgentNameKey).toBeNull();
|
|
||||||
expect(inProgressIssue?.executionLockedAt).toBeNull();
|
|
||||||
|
|
||||||
const [todoIssue] = await db.select().from(issues).where(eq(issues.id, todoIssueId));
|
|
||||||
expect(todoIssue?.status).toBe("todo");
|
|
||||||
expect(todoIssue?.assigneeAgentId).toBeNull();
|
|
||||||
|
|
||||||
const [reviewIssue] = await db.select().from(issues).where(eq(issues.id, reviewIssueId));
|
|
||||||
expect(reviewIssue?.status).toBe("in_review");
|
|
||||||
expect(reviewIssue?.assigneeAgentId).toBeNull();
|
|
||||||
|
|
||||||
const [userIssue] = await db.select().from(issues).where(eq(issues.id, userIssueId));
|
|
||||||
expect(userIssue?.status).toBe("todo");
|
|
||||||
expect(userIssue?.assigneeUserId).toBe("user-1");
|
|
||||||
|
|
||||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, inProgressIssueId));
|
|
||||||
expect(comments).toHaveLength(1);
|
|
||||||
expect(comments[0]?.body).toContain("Quarantined during worktree seed");
|
|
||||||
} finally {
|
|
||||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
|
||||||
await tempDb.cleanup();
|
|
||||||
}
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||||
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||||
@@ -512,97 +350,6 @@ describe("worktree helpers", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
itEmbeddedPostgres(
|
|
||||||
"seeds authenticated users into minimally cloned worktree instances",
|
|
||||||
async () => {
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-"));
|
|
||||||
const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed");
|
|
||||||
const sourceHome = path.join(tempRoot, "source-home");
|
|
||||||
const sourceConfigDir = path.join(sourceHome, "instances", "source");
|
|
||||||
const sourceConfigPath = path.join(sourceConfigDir, "config.json");
|
|
||||||
const sourceEnvPath = path.join(sourceConfigDir, ".env");
|
|
||||||
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
|
|
||||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sourceDbClient = createDb(sourceDb.connectionString);
|
|
||||||
await sourceDbClient.insert(authUsers).values({
|
|
||||||
id: "user-existing",
|
|
||||||
email: "existing@paperclip.ing",
|
|
||||||
name: "Existing User",
|
|
||||||
emailVerified: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
|
||||||
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
||||||
|
|
||||||
const sourceConfig = buildSourceConfig();
|
|
||||||
sourceConfig.database = {
|
|
||||||
mode: "postgres",
|
|
||||||
embeddedPostgresDataDir: path.join(sourceConfigDir, "db"),
|
|
||||||
embeddedPostgresPort: 54329,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(sourceConfigDir, "backups"),
|
|
||||||
},
|
|
||||||
connectionString: sourceDb.connectionString,
|
|
||||||
};
|
|
||||||
sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs");
|
|
||||||
sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage");
|
|
||||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
|
||||||
|
|
||||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8");
|
|
||||||
fs.writeFileSync(sourceEnvPath, "", "utf8");
|
|
||||||
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
|
||||||
|
|
||||||
process.chdir(worktreeRoot);
|
|
||||||
await worktreeInitCommand({
|
|
||||||
name: "PAP-999-auth-seed",
|
|
||||||
home: worktreeHome,
|
|
||||||
fromConfig: sourceConfigPath,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetConfig = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"),
|
|
||||||
) as PaperclipConfig;
|
|
||||||
const { default: EmbeddedPostgres } = await import("embedded-postgres");
|
|
||||||
const targetPg = new EmbeddedPostgres({
|
|
||||||
databaseDir: targetConfig.database.embeddedPostgresDataDir,
|
|
||||||
user: "paperclip",
|
|
||||||
password: "paperclip",
|
|
||||||
port: targetConfig.database.embeddedPostgresPort,
|
|
||||||
persistent: true,
|
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
|
||||||
onLog: () => {},
|
|
||||||
onError: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await targetPg.start();
|
|
||||||
try {
|
|
||||||
const targetDb = createDb(
|
|
||||||
`postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`,
|
|
||||||
);
|
|
||||||
const seededUsers = await targetDb.select().from(authUsers);
|
|
||||||
expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await targetPg.stop();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
await sourceDb.cleanup();
|
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
30000,
|
|
||||||
);
|
|
||||||
|
|
||||||
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
||||||
const repoRoot = path.join(tempRoot, "repo");
|
const repoRoot = path.join(tempRoot, "repo");
|
||||||
@@ -882,7 +629,7 @@ describe("worktree helpers", () => {
|
|||||||
}
|
}
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("restores the current worktree config and instance data if reseed fails", async () => {
|
it("restores the current worktree config and instance data if reseed fails", async () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
||||||
@@ -1039,7 +786,7 @@ describe("worktree helpers", () => {
|
|||||||
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 15_000);
|
});
|
||||||
|
|
||||||
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
||||||
@@ -1075,246 +822,4 @@ describe("worktree helpers", () => {
|
|||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("no-ops on the primary checkout unless --branch is provided", async () => {
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-"));
|
|
||||||
const repoRoot = path.join(tempRoot, "repo");
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(repoRoot, { recursive: true });
|
|
||||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
|
|
||||||
process.chdir(repoRoot);
|
|
||||||
await worktreeRepairCommand({});
|
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "config.json"))).toBe(false);
|
|
||||||
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "worktrees"))).toBe(false);
|
|
||||||
} finally {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repairs the current linked worktree when Paperclip metadata is missing", async () => {
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-"));
|
|
||||||
const repoRoot = path.join(tempRoot, "repo");
|
|
||||||
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me");
|
|
||||||
const sourceConfigPath = path.join(tempRoot, "source-config.json");
|
|
||||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
const worktreePaths = resolveWorktreeLocalPaths({
|
|
||||||
cwd: worktreePath,
|
|
||||||
homeDir: worktreeHome,
|
|
||||||
instanceId: sanitizeWorktreeInstanceId(path.basename(worktreePath)),
|
|
||||||
});
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(repoRoot, { recursive: true });
|
|
||||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
||||||
execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], {
|
|
||||||
cwd: repoRoot,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
|
|
||||||
fs.mkdirSync(worktreePaths.instanceRoot, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(worktreePaths.instanceRoot, "marker.txt"), "stale", "utf8");
|
|
||||||
|
|
||||||
process.chdir(worktreePath);
|
|
||||||
await worktreeRepairCommand({
|
|
||||||
fromConfig: sourceConfigPath,
|
|
||||||
home: worktreeHome,
|
|
||||||
noSeed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
|
||||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
|
||||||
expect(fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt"))).toBe(false);
|
|
||||||
} finally {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
it("creates and repairs a missing branch worktree when --branch is provided", async () => {
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-"));
|
|
||||||
const repoRoot = path.join(tempRoot, "repo");
|
|
||||||
const sourceConfigPath = path.join(tempRoot, "source-config.json");
|
|
||||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
const expectedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", "feature-repair-me");
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(repoRoot, { recursive: true });
|
|
||||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
|
||||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
|
|
||||||
|
|
||||||
process.chdir(repoRoot);
|
|
||||||
await worktreeRepairCommand({
|
|
||||||
branch: "feature/repair-me",
|
|
||||||
fromConfig: sourceConfigPath,
|
|
||||||
home: worktreeHome,
|
|
||||||
noSeed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".git"))).toBe(true);
|
|
||||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", "config.json"))).toBe(true);
|
|
||||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", ".env"))).toBe(true);
|
|
||||||
} finally {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 20_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => {
|
|
||||||
it("pauses only routines with enabled schedule triggers", async () => {
|
|
||||||
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-routines-");
|
|
||||||
const db = createDb(tempDb.connectionString);
|
|
||||||
const companyId = randomUUID();
|
|
||||||
const projectId = randomUUID();
|
|
||||||
const agentId = randomUUID();
|
|
||||||
const activeScheduledRoutineId = randomUUID();
|
|
||||||
const activeApiRoutineId = randomUUID();
|
|
||||||
const pausedScheduledRoutineId = randomUUID();
|
|
||||||
const archivedScheduledRoutineId = randomUUID();
|
|
||||||
const disabledScheduleRoutineId = randomUUID();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(companies).values({
|
|
||||||
id: companyId,
|
|
||||||
name: "Paperclip",
|
|
||||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
||||||
requireBoardApprovalForNewAgents: false,
|
|
||||||
});
|
|
||||||
await db.insert(agents).values({
|
|
||||||
id: agentId,
|
|
||||||
companyId,
|
|
||||||
name: "Coder",
|
|
||||||
adapterType: "process",
|
|
||||||
adapterConfig: {},
|
|
||||||
runtimeConfig: {},
|
|
||||||
permissions: {},
|
|
||||||
});
|
|
||||||
await db.insert(projects).values({
|
|
||||||
id: projectId,
|
|
||||||
companyId,
|
|
||||||
name: "Project",
|
|
||||||
status: "in_progress",
|
|
||||||
});
|
|
||||||
await db.insert(routines).values([
|
|
||||||
{
|
|
||||||
id: activeScheduledRoutineId,
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
title: "Active scheduled",
|
|
||||||
status: "active",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: activeApiRoutineId,
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
title: "Active API",
|
|
||||||
status: "active",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: pausedScheduledRoutineId,
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
title: "Paused scheduled",
|
|
||||||
status: "paused",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: archivedScheduledRoutineId,
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
title: "Archived scheduled",
|
|
||||||
status: "archived",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: disabledScheduleRoutineId,
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
assigneeAgentId: agentId,
|
|
||||||
title: "Disabled schedule",
|
|
||||||
status: "active",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await db.insert(routineTriggers).values([
|
|
||||||
{
|
|
||||||
companyId,
|
|
||||||
routineId: activeScheduledRoutineId,
|
|
||||||
kind: "schedule",
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: "0 9 * * *",
|
|
||||||
timezone: "UTC",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
companyId,
|
|
||||||
routineId: activeApiRoutineId,
|
|
||||||
kind: "api",
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
companyId,
|
|
||||||
routineId: pausedScheduledRoutineId,
|
|
||||||
kind: "schedule",
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: "0 10 * * *",
|
|
||||||
timezone: "UTC",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
companyId,
|
|
||||||
routineId: archivedScheduledRoutineId,
|
|
||||||
kind: "schedule",
|
|
||||||
enabled: true,
|
|
||||||
cronExpression: "0 11 * * *",
|
|
||||||
timezone: "UTC",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
companyId,
|
|
||||||
routineId: disabledScheduleRoutineId,
|
|
||||||
kind: "schedule",
|
|
||||||
enabled: false,
|
|
||||||
cronExpression: "0 12 * * *",
|
|
||||||
timezone: "UTC",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const pausedCount = await pauseSeededScheduledRoutines(tempDb.connectionString);
|
|
||||||
expect(pausedCount).toBe(1);
|
|
||||||
|
|
||||||
const rows = await db.select({ id: routines.id, status: routines.status }).from(routines);
|
|
||||||
const statusById = new Map(rows.map((row) => [row.id, row.status]));
|
|
||||||
expect(statusById.get(activeScheduledRoutineId)).toBe("paused");
|
|
||||||
expect(statusById.get(activeApiRoutineId)).toBe("active");
|
|
||||||
expect(statusById.get(pausedScheduledRoutineId)).toBe("paused");
|
|
||||||
expect(statusById.get(archivedScheduledRoutineId)).toBe("archived");
|
|
||||||
expect(statusById.get(disabledScheduleRoutineId)).toBe("active");
|
|
||||||
} finally {
|
|
||||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
|
||||||
await tempDb.cleanup();
|
|
||||||
}
|
|
||||||
}, 20_000);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||||
import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli";
|
|
||||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-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 { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||||
@@ -16,11 +14,6 @@ const claudeLocalCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printClaudeStreamEvent,
|
formatStdoutEvent: printClaudeStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const acpxLocalCLIAdapter: CLIAdapterModule = {
|
|
||||||
type: "acpx_local",
|
|
||||||
formatStdoutEvent: printAcpxStreamEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const codexLocalCLIAdapter: CLIAdapterModule = {
|
const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||||
type: "codex_local",
|
type: "codex_local",
|
||||||
formatStdoutEvent: printCodexStreamEvent,
|
formatStdoutEvent: printCodexStreamEvent,
|
||||||
@@ -41,11 +34,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printCursorStreamEvent,
|
formatStdoutEvent: printCursorStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cursorCloudCLIAdapter: CLIAdapterModule = {
|
|
||||||
type: "cursor_cloud",
|
|
||||||
formatStdoutEvent: printCursorCloudEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||||
type: "gemini_local",
|
type: "gemini_local",
|
||||||
formatStdoutEvent: printGeminiStreamEvent,
|
formatStdoutEvent: printGeminiStreamEvent,
|
||||||
@@ -58,13 +46,11 @@ const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
|||||||
|
|
||||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||||
[
|
[
|
||||||
acpxLocalCLIAdapter,
|
|
||||||
claudeLocalCLIAdapter,
|
claudeLocalCLIAdapter,
|
||||||
codexLocalCLIAdapter,
|
codexLocalCLIAdapter,
|
||||||
openCodeLocalCLIAdapter,
|
openCodeLocalCLIAdapter,
|
||||||
piLocalCLIAdapter,
|
piLocalCLIAdapter,
|
||||||
cursorLocalCLIAdapter,
|
cursorLocalCLIAdapter,
|
||||||
cursorCloudCLIAdapter,
|
|
||||||
geminiLocalCLIAdapter,
|
geminiLocalCLIAdapter,
|
||||||
openclawGatewayCLIAdapter,
|
openclawGatewayCLIAdapter,
|
||||||
processCLIAdapter,
|
processCLIAdapter,
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { inferBindModeFromHost } from "@paperclipai/shared";
|
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import type { CheckResult } from "./index.js";
|
import type { CheckResult } from "./index.js";
|
||||||
|
|
||||||
|
function isLoopbackHost(host: string) {
|
||||||
|
const normalized = host.trim().toLowerCase();
|
||||||
|
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
||||||
const mode = config.server.deploymentMode;
|
const mode = config.server.deploymentMode;
|
||||||
const exposure = config.server.exposure;
|
const exposure = config.server.exposure;
|
||||||
const auth = config.auth;
|
const auth = config.auth;
|
||||||
const bind = config.server.bind ?? inferBindModeFromHost(config.server.host);
|
|
||||||
|
|
||||||
if (mode === "local_trusted") {
|
if (mode === "local_trusted") {
|
||||||
if (bind !== "loopback") {
|
if (!isLoopbackHost(config.server.host)) {
|
||||||
return {
|
return {
|
||||||
name: "Deployment/auth mode",
|
name: "Deployment/auth mode",
|
||||||
status: "fail",
|
status: "fail",
|
||||||
message: `local_trusted requires loopback binding (found ${bind})`,
|
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
|
||||||
canRepair: false,
|
canRepair: false,
|
||||||
repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability",
|
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -83,6 +86,6 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
|||||||
return {
|
return {
|
||||||
name: "Deployment/auth mode",
|
name: "Deployment/auth mode",
|
||||||
status: "pass",
|
status: "pass",
|
||||||
message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`,
|
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import type { PaperclipConfig } from "../config/schema.js";
|
|||||||
import type { CheckResult } from "./index.js";
|
import type { CheckResult } from "./index.js";
|
||||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||||
|
|
||||||
const AWS_CREDENTIAL_SOURCE_HINT =
|
|
||||||
"Provide AWS runtime credentials through the AWS SDK default credential chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials";
|
|
||||||
|
|
||||||
function decodeMasterKey(raw: string): Buffer | null {
|
function decodeMasterKey(raw: string): Buffer | null {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -50,16 +47,13 @@ function withStrictModeNote(
|
|||||||
|
|
||||||
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||||
const provider = config.secrets.provider;
|
const provider = config.secrets.provider;
|
||||||
if (provider === "aws_secrets_manager") {
|
|
||||||
return withStrictModeNote(awsSecretsManagerCheck(), config);
|
|
||||||
}
|
|
||||||
if (provider !== "local_encrypted") {
|
if (provider !== "local_encrypted") {
|
||||||
return {
|
return {
|
||||||
name: "Secrets adapter",
|
name: "Secrets adapter",
|
||||||
status: "fail",
|
status: "fail",
|
||||||
message: `${provider} is configured, but this build only supports local_encrypted and aws_secrets_manager`,
|
message: `${provider} is configured, but this build only supports local_encrypted`,
|
||||||
canRepair: false,
|
canRepair: false,
|
||||||
repairHint: "Run `paperclipai configure --section secrets` and choose local_encrypted or aws_secrets_manager",
|
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,100 +135,12 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyMode = fs.statSync(keyFilePath).mode & 0o777;
|
|
||||||
const permissionWarning =
|
|
||||||
(keyMode & 0o077) !== 0
|
|
||||||
? `; key file permissions are ${keyMode.toString(8)} (run chmod 600 ${keyFilePath})`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return withStrictModeNote(
|
return withStrictModeNote(
|
||||||
{
|
{
|
||||||
name: "Secrets adapter",
|
name: "Secrets adapter",
|
||||||
status: permissionWarning ? "warn" : "pass",
|
status: "pass",
|
||||||
message: `Local encrypted provider configured with key file ${keyFilePath}${permissionWarning}`,
|
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
||||||
repairHint: permissionWarning
|
|
||||||
? "Restrict the local encrypted secrets key file to owner read/write permissions"
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function awsSecretsManagerCheck(): CheckResult {
|
|
||||||
const missingConfig = missingAwsSecretsManagerConfig();
|
|
||||||
if (missingConfig.length > 0) {
|
|
||||||
return {
|
|
||||||
name: "Secrets adapter",
|
|
||||||
status: "fail",
|
|
||||||
message: `AWS Secrets Manager provider is missing non-secret config: ${missingConfig.join(", ")}`,
|
|
||||||
canRepair: false,
|
|
||||||
repairHint:
|
|
||||||
`Set ${missingConfig.join(", ")} in the Paperclip server runtime. ${AWS_CREDENTIAL_SOURCE_HINT}. Do not store AWS root credentials or long-lived IAM user keys in Paperclip secrets.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const staticEnvCredentials =
|
|
||||||
process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
|
||||||
const credentialSource = detectedAwsCredentialSources().join(", ");
|
|
||||||
const message =
|
|
||||||
`AWS Secrets Manager provider configured for deployment ${process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID}; ` +
|
|
||||||
`runtime credentials source: ${credentialSource || "AWS SDK default credential chain"}`;
|
|
||||||
|
|
||||||
if (staticEnvCredentials) {
|
|
||||||
return {
|
|
||||||
name: "Secrets adapter",
|
|
||||||
status: "warn",
|
|
||||||
message,
|
|
||||||
canRepair: false,
|
|
||||||
repairHint:
|
|
||||||
"AWS static environment credentials are visible. Use only short-lived shell credentials locally; prefer IAM role/workload identity for hosted deployments and never store AWS access keys in Paperclip company secrets.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: "Secrets adapter",
|
|
||||||
status: "pass",
|
|
||||||
message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function missingAwsSecretsManagerConfig(): string[] {
|
|
||||||
const missing: string[] = [];
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() ||
|
|
||||||
process.env.AWS_REGION?.trim() ||
|
|
||||||
process.env.AWS_DEFAULT_REGION?.trim()
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
missing.push("PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION");
|
|
||||||
}
|
|
||||||
if (!process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim()) {
|
|
||||||
missing.push("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
|
||||||
}
|
|
||||||
if (!process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim()) {
|
|
||||||
missing.push("PAPERCLIP_SECRETS_AWS_KMS_KEY_ID");
|
|
||||||
}
|
|
||||||
return missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectedAwsCredentialSources(): string[] {
|
|
||||||
const sources: string[] = [];
|
|
||||||
if (process.env.AWS_PROFILE?.trim()) sources.push("AWS_PROFILE/shared config");
|
|
||||||
if (process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()) {
|
|
||||||
sources.push("temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment credentials");
|
|
||||||
}
|
|
||||||
if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE?.trim() && process.env.AWS_ROLE_ARN?.trim()) {
|
|
||||||
sources.push("AWS web identity token");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI?.trim() ||
|
|
||||||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI?.trim()
|
|
||||||
) {
|
|
||||||
sources.push("AWS container credentials endpoint");
|
|
||||||
}
|
|
||||||
if (process.env.AWS_SHARED_CREDENTIALS_FILE?.trim() || process.env.AWS_CONFIG_FILE?.trim()) {
|
|
||||||
sources.push("custom AWS shared credentials/config file");
|
|
||||||
}
|
|
||||||
return sources;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as p from "@clack/prompts";
|
|||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||||
import { inferBindModeFromHost } from "@paperclipai/shared";
|
|
||||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
|
|
||||||
@@ -41,13 +40,9 @@ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
|
|||||||
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
||||||
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host);
|
const host = config?.server.host ?? "localhost";
|
||||||
const host =
|
|
||||||
bind === "custom"
|
|
||||||
? config?.server.customBindHost ?? config?.server.host ?? "localhost"
|
|
||||||
: config?.server.host ?? "localhost";
|
|
||||||
const port = config?.server.port ?? 3100;
|
const port = config?.server.port ?? 3100;
|
||||||
const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host;
|
const publicHost = host === "0.0.0.0" ? "localhost" : host;
|
||||||
return `http://${publicHost}:${port}`;
|
return `http://${publicHost}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ interface IssueUpdateOptions extends BaseClientOptions {
|
|||||||
interface IssueCommentOptions extends BaseClientOptions {
|
interface IssueCommentOptions extends BaseClientOptions {
|
||||||
body: string;
|
body: string;
|
||||||
reopen?: boolean;
|
reopen?: boolean;
|
||||||
resume?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssueCheckoutOptions extends BaseClientOptions {
|
interface IssueCheckoutOptions extends BaseClientOptions {
|
||||||
@@ -242,14 +241,12 @@ export function registerIssueCommands(program: Command): void {
|
|||||||
.argument("<issueId>", "Issue ID")
|
.argument("<issueId>", "Issue ID")
|
||||||
.requiredOption("--body <text>", "Comment body")
|
.requiredOption("--body <text>", "Comment body")
|
||||||
.option("--reopen", "Reopen if issue is done/cancelled")
|
.option("--reopen", "Reopen if issue is done/cancelled")
|
||||||
.option("--resume", "Request explicit follow-up and wake the assignee when resumable")
|
|
||||||
.action(async (issueId: string, opts: IssueCommentOptions) => {
|
.action(async (issueId: string, opts: IssueCommentOptions) => {
|
||||||
try {
|
try {
|
||||||
const ctx = resolveCommandContext(opts);
|
const ctx = resolveCommandContext(opts);
|
||||||
const payload = addIssueCommentSchema.parse({
|
const payload = addIssueCommentSchema.parse({
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
reopen: opts.reopen,
|
reopen: opts.reopen,
|
||||||
resume: opts.resume,
|
|
||||||
});
|
});
|
||||||
const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload);
|
const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload);
|
||||||
printOutput(comment, { json: ctx.json });
|
printOutput(comment, { json: ctx.json });
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { Command } from "commander";
|
||||||
import { Command, Option } from "commander";
|
|
||||||
import {
|
|
||||||
scaffoldPluginProject,
|
|
||||||
shellQuote,
|
|
||||||
type ScaffoldPluginOptions,
|
|
||||||
} from "../../../../packages/plugins/create-paperclip-plugin/src/index.js";
|
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import {
|
import {
|
||||||
addCommonClientOptions,
|
addCommonClientOptions,
|
||||||
@@ -45,101 +39,28 @@ interface PluginInstallOptions extends BaseClientOptions {
|
|||||||
version?: string;
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginInstallRequest {
|
|
||||||
packageName: string;
|
|
||||||
version?: string;
|
|
||||||
isLocalPath: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginUninstallOptions extends BaseClientOptions {
|
interface PluginUninstallOptions extends BaseClientOptions {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginInitOptions extends BaseClientOptions {
|
|
||||||
output?: string;
|
|
||||||
template?: ScaffoldPluginOptions["template"];
|
|
||||||
category?: ScaffoldPluginOptions["category"];
|
|
||||||
displayName?: string;
|
|
||||||
description?: string;
|
|
||||||
author?: string;
|
|
||||||
sdkPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginInitResult {
|
|
||||||
outputDir: string;
|
|
||||||
nextCommands: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function expandHomePath(packageArg: string): string {
|
|
||||||
if (!packageArg.startsWith("~")) return packageArg;
|
|
||||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
||||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasLocalPathSyntax(packageArg: string): boolean {
|
|
||||||
return (
|
|
||||||
path.isAbsolute(packageArg) ||
|
|
||||||
packageArg.startsWith("./") ||
|
|
||||||
packageArg.startsWith("../") ||
|
|
||||||
packageArg.startsWith("~") ||
|
|
||||||
packageArg.startsWith(".\\") ||
|
|
||||||
packageArg.startsWith("..\\")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExistingRelativePath(
|
|
||||||
packageArg: string,
|
|
||||||
cwd: string,
|
|
||||||
pathExists: (targetPath: string) => boolean,
|
|
||||||
): boolean {
|
|
||||||
if (packageArg.trim() === "") return false;
|
|
||||||
if (hasLocalPathSyntax(packageArg)) return false;
|
|
||||||
return pathExists(path.resolve(cwd, packageArg));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a local path argument to an absolute path so the server can find the
|
* Resolve a local path argument to an absolute path so the server can find the
|
||||||
* plugin on disk regardless of where the user ran the CLI.
|
* plugin on disk regardless of where the user ran the CLI.
|
||||||
*/
|
*/
|
||||||
function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): string {
|
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||||
if (!isLocal) return packageArg;
|
if (!isLocal) return packageArg;
|
||||||
|
// Already absolute
|
||||||
if (path.isAbsolute(packageArg)) return packageArg;
|
if (path.isAbsolute(packageArg)) return packageArg;
|
||||||
if (packageArg.startsWith("~")) return expandHomePath(packageArg);
|
// Expand leading ~ to home directory
|
||||||
return path.resolve(cwd, packageArg);
|
if (packageArg.startsWith("~")) {
|
||||||
}
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||||
|
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||||
export function buildPluginInstallRequest(
|
|
||||||
packageArg: string,
|
|
||||||
opts: Pick<PluginInstallOptions, "local" | "version"> = {},
|
|
||||||
deps: { cwd?: string; existsSync?: (targetPath: string) => boolean } = {},
|
|
||||||
): PluginInstallRequest {
|
|
||||||
const cwd = deps.cwd ?? process.cwd();
|
|
||||||
const pathExists = deps.existsSync ?? existsSync;
|
|
||||||
const isLocal =
|
|
||||||
opts.local ||
|
|
||||||
hasLocalPathSyntax(packageArg) ||
|
|
||||||
(opts.version ? false : isExistingRelativePath(packageArg, cwd, pathExists));
|
|
||||||
|
|
||||||
if (isLocal && opts.version) {
|
|
||||||
throw new Error("--version is only supported for npm package installs, not local plugin paths.");
|
|
||||||
}
|
}
|
||||||
|
return path.resolve(process.cwd(), packageArg);
|
||||||
return {
|
|
||||||
packageName: resolvePackageArg(packageArg, Boolean(isLocal), cwd),
|
|
||||||
version: opts.version,
|
|
||||||
isLocalPath: Boolean(isLocal),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderLocalPluginInstallHint(packagePath: string): string {
|
|
||||||
return [
|
|
||||||
pc.dim("Local plugin installs run trusted local code from your machine."),
|
|
||||||
pc.dim(`Keep ${pc.cyan("pnpm dev")} running in ${packagePath}; Paperclip watches rebuilt dist output and reloads the plugin worker.`),
|
|
||||||
].join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPlugin(p: PluginRecord): string {
|
function formatPlugin(p: PluginRecord): string {
|
||||||
@@ -166,58 +87,6 @@ function formatPlugin(p: PluginRecord): string {
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function packageToDirName(pluginName: string): string {
|
|
||||||
return pluginName.replace(/^@[^/]+\//, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPluginInitScaffoldOptions(
|
|
||||||
packageName: string,
|
|
||||||
opts: PluginInitOptions,
|
|
||||||
cwd = process.cwd(),
|
|
||||||
): ScaffoldPluginOptions {
|
|
||||||
const outputRoot = path.resolve(cwd, opts.output ?? ".");
|
|
||||||
const outputDir = path.resolve(outputRoot, packageToDirName(packageName));
|
|
||||||
|
|
||||||
return {
|
|
||||||
pluginName: packageName,
|
|
||||||
outputDir,
|
|
||||||
template: opts.template,
|
|
||||||
category: opts.category,
|
|
||||||
displayName: opts.displayName,
|
|
||||||
description: opts.description,
|
|
||||||
author: opts.author,
|
|
||||||
sdkPath: opts.sdkPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPluginInitNextCommands(outputDir: string): string[] {
|
|
||||||
const quotedOutputDir = shellQuote(outputDir);
|
|
||||||
return [
|
|
||||||
`cd ${quotedOutputDir}`,
|
|
||||||
"pnpm install",
|
|
||||||
"pnpm dev",
|
|
||||||
`paperclipai plugin install ${quotedOutputDir}`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderPluginInitSuccess(result: PluginInitResult): string {
|
|
||||||
return [
|
|
||||||
pc.green(`✓ Created plugin scaffold at ${result.outputDir}`),
|
|
||||||
"",
|
|
||||||
"Next commands:",
|
|
||||||
...result.nextCommands.map((command) => ` ${pc.cyan(command)}`),
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runPluginInitCommand(packageName: string, opts: PluginInitOptions): PluginInitResult {
|
|
||||||
const scaffoldOptions = buildPluginInitScaffoldOptions(packageName, opts);
|
|
||||||
const outputDir = scaffoldPluginProject(scaffoldOptions);
|
|
||||||
return {
|
|
||||||
outputDir,
|
|
||||||
nextCommands: buildPluginInitNextCommands(outputDir),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Command registration
|
// Command registration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -225,43 +94,6 @@ export function runPluginInitCommand(packageName: string, opts: PluginInitOption
|
|||||||
export function registerPluginCommands(program: Command): void {
|
export function registerPluginCommands(program: Command): void {
|
||||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// plugin init <package-name>
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
addCommonClientOptions(
|
|
||||||
plugin
|
|
||||||
.command("init <packageName>")
|
|
||||||
.description("Scaffold a local Paperclip plugin project")
|
|
||||||
.option("--output <dir>", "Directory to create the plugin folder in")
|
|
||||||
.addOption(
|
|
||||||
new Option("--template <template>", "Starter template")
|
|
||||||
.choices(["default", "connector", "workspace", "environment"])
|
|
||||||
.default("default"),
|
|
||||||
)
|
|
||||||
.addOption(
|
|
||||||
new Option("--category <category>", "Manifest category")
|
|
||||||
.choices(["connector", "workspace", "automation", "ui", "environment"]),
|
|
||||||
)
|
|
||||||
.option("--display-name <name>", "Manifest display name")
|
|
||||||
.option("--description <description>", "Manifest description")
|
|
||||||
.option("--author <author>", "Manifest author")
|
|
||||||
.option("--sdk-path <path>", "Local @paperclipai/plugin-sdk package path")
|
|
||||||
.action((packageName: string, opts: PluginInitOptions) => {
|
|
||||||
try {
|
|
||||||
const result = runPluginInitCommand(packageName, opts);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printOutput(result, { json: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(renderPluginInitSuccess(result));
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// plugin list
|
// plugin list
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -315,19 +147,31 @@ export function registerPluginCommands(program: Command): void {
|
|||||||
try {
|
try {
|
||||||
const ctx = resolveCommandContext(opts);
|
const ctx = resolveCommandContext(opts);
|
||||||
|
|
||||||
const installRequest = buildPluginInstallRequest(packageArg, opts);
|
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||||
|
const isLocal =
|
||||||
|
opts.local ||
|
||||||
|
packageArg.startsWith("./") ||
|
||||||
|
packageArg.startsWith("../") ||
|
||||||
|
packageArg.startsWith("/") ||
|
||||||
|
packageArg.startsWith("~");
|
||||||
|
|
||||||
|
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||||
|
|
||||||
if (!ctx.json) {
|
if (!ctx.json) {
|
||||||
console.log(
|
console.log(
|
||||||
pc.dim(
|
pc.dim(
|
||||||
installRequest.isLocalPath
|
isLocal
|
||||||
? `Installing plugin from local path: ${installRequest.packageName}`
|
? `Installing plugin from local path: ${resolvedPackage}`
|
||||||
: `Installing plugin: ${installRequest.packageName}${opts.version ? `@${opts.version}` : ""}`,
|
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", installRequest);
|
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||||
|
packageName: resolvedPackage,
|
||||||
|
version: opts.version,
|
||||||
|
isLocalPath: isLocal,
|
||||||
|
});
|
||||||
|
|
||||||
if (ctx.json) {
|
if (ctx.json) {
|
||||||
printOutput(installedPlugin, { json: true });
|
printOutput(installedPlugin, { json: true });
|
||||||
@@ -348,10 +192,6 @@ export function registerPluginCommands(program: Command): void {
|
|||||||
if (installedPlugin.lastError) {
|
if (installedPlugin.lastError) {
|
||||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (installRequest.isLocalPath) {
|
|
||||||
console.log(renderLocalPluginInstallHint(installRequest.packageName));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleCommandError(err);
|
handleCommandError(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,501 +0,0 @@
|
|||||||
import { Command } from "commander";
|
|
||||||
import pc from "picocolors";
|
|
||||||
import type {
|
|
||||||
Agent,
|
|
||||||
AgentEnvConfig,
|
|
||||||
CompanyPortabilityEnvInput,
|
|
||||||
CompanyPortabilityExportPreviewResult,
|
|
||||||
CompanyPortabilityInclude,
|
|
||||||
CompanySecret,
|
|
||||||
EnvBinding,
|
|
||||||
SecretProvider,
|
|
||||||
SecretProviderDescriptor,
|
|
||||||
} from "@paperclipai/shared";
|
|
||||||
import {
|
|
||||||
addCommonClientOptions,
|
|
||||||
formatInlineRecord,
|
|
||||||
handleCommandError,
|
|
||||||
printOutput,
|
|
||||||
resolveCommandContext,
|
|
||||||
type BaseClientOptions,
|
|
||||||
} from "./common.js";
|
|
||||||
|
|
||||||
interface SecretListOptions extends BaseClientOptions {
|
|
||||||
companyId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecretDeclarationsOptions extends BaseClientOptions {
|
|
||||||
companyId?: string;
|
|
||||||
include?: string;
|
|
||||||
kind?: "all" | "secret" | "plain";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecretCreateOptions extends BaseClientOptions {
|
|
||||||
companyId?: string;
|
|
||||||
name?: string;
|
|
||||||
key?: string;
|
|
||||||
provider?: SecretProvider;
|
|
||||||
value?: string;
|
|
||||||
valueEnv?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecretLinkOptions extends BaseClientOptions {
|
|
||||||
companyId?: string;
|
|
||||||
name?: string;
|
|
||||||
key?: string;
|
|
||||||
provider?: SecretProvider;
|
|
||||||
externalRef?: string;
|
|
||||||
providerVersionRef?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecretDoctorOptions extends BaseClientOptions {
|
|
||||||
companyId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecretMigrateInlineEnvOptions extends BaseClientOptions {
|
|
||||||
companyId?: string;
|
|
||||||
apply?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecretProviderHealth {
|
|
||||||
provider: SecretProvider;
|
|
||||||
status: "ok" | "warn" | "error";
|
|
||||||
message: string;
|
|
||||||
warnings?: string[];
|
|
||||||
backupGuidance?: string[];
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecretProviderHealthResponse {
|
|
||||||
providers: SecretProviderHealth[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InlineSecretMigrationCandidate {
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
envKey: string;
|
|
||||||
secretName: string;
|
|
||||||
existingSecretId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENSITIVE_ENV_KEY_RE =
|
|
||||||
/(^token$|[-_]?token$|api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
|
||||||
|
|
||||||
const DEFAULT_DECLARATION_INCLUDE: CompanyPortabilityInclude = {
|
|
||||||
company: true,
|
|
||||||
agents: true,
|
|
||||||
projects: true,
|
|
||||||
issues: false,
|
|
||||||
skills: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseSecretsInclude(input: string | undefined): CompanyPortabilityInclude {
|
|
||||||
if (!input?.trim()) return { ...DEFAULT_DECLARATION_INCLUDE };
|
|
||||||
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
||||||
const include = {
|
|
||||||
company: values.includes("company"),
|
|
||||||
agents: values.includes("agents"),
|
|
||||||
projects: values.includes("projects"),
|
|
||||||
issues: values.includes("issues") || values.includes("tasks"),
|
|
||||||
skills: values.includes("skills"),
|
|
||||||
};
|
|
||||||
if (!Object.values(include).some(Boolean)) {
|
|
||||||
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
|
|
||||||
}
|
|
||||||
return include;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSensitiveEnvKey(key: string): boolean {
|
|
||||||
return SENSITIVE_ENV_KEY_RE.test(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toPlainEnvValue(binding: unknown): string | null {
|
|
||||||
if (typeof binding === "string") return binding;
|
|
||||||
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
|
|
||||||
const record = binding as Record<string, unknown>;
|
|
||||||
if (record.type === "plain" && typeof record.value === "string") return record.value;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildInlineMigrationSecretName(agentId: string, key: string): string {
|
|
||||||
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectInlineSecretMigrationCandidates(
|
|
||||||
agents: Agent[],
|
|
||||||
existingSecrets: CompanySecret[],
|
|
||||||
): InlineSecretMigrationCandidate[] {
|
|
||||||
const secretByName = new Map(existingSecrets.map((secret) => [secret.name, secret]));
|
|
||||||
const candidates: InlineSecretMigrationCandidate[] = [];
|
|
||||||
|
|
||||||
for (const agent of agents) {
|
|
||||||
const env = asRecord(agent.adapterConfig.env);
|
|
||||||
if (!env) continue;
|
|
||||||
for (const [envKey, binding] of Object.entries(env)) {
|
|
||||||
if (!isSensitiveEnvKey(envKey)) continue;
|
|
||||||
const plain = toPlainEnvValue(binding);
|
|
||||||
if (plain === null || plain.trim().length === 0) continue;
|
|
||||||
const secretName = buildInlineMigrationSecretName(agent.id, envKey);
|
|
||||||
candidates.push({
|
|
||||||
agentId: agent.id,
|
|
||||||
agentName: agent.name,
|
|
||||||
envKey,
|
|
||||||
secretName,
|
|
||||||
existingSecretId: secretByName.get(secretName)?.id ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMigratedAgentEnv(
|
|
||||||
env: Record<string, unknown>,
|
|
||||||
secretIdByEnvKey: Map<string, string>,
|
|
||||||
): AgentEnvConfig {
|
|
||||||
const next: AgentEnvConfig = { ...(env as Record<string, EnvBinding>) };
|
|
||||||
for (const [envKey, secretId] of secretIdByEnvKey) {
|
|
||||||
next[envKey] = {
|
|
||||||
type: "secret_ref",
|
|
||||||
secretId,
|
|
||||||
version: "latest",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readValueFromOptions(opts: SecretCreateOptions): string {
|
|
||||||
if (opts.value !== undefined && opts.valueEnv !== undefined) {
|
|
||||||
throw new Error("Use only one of --value or --value-env.");
|
|
||||||
}
|
|
||||||
if (opts.valueEnv !== undefined) {
|
|
||||||
const value = process.env[opts.valueEnv];
|
|
||||||
if (!value) throw new Error(`Environment variable ${opts.valueEnv} is empty or unset.`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (opts.value !== undefined) return opts.value;
|
|
||||||
throw new Error("Secret value is required. Pass --value or --value-env.");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDeclaration(input: CompanyPortabilityEnvInput): Record<string, unknown> {
|
|
||||||
const scope = input.agentSlug
|
|
||||||
? `agent:${input.agentSlug}`
|
|
||||||
: input.projectSlug
|
|
||||||
? `project:${input.projectSlug}`
|
|
||||||
: "company";
|
|
||||||
return {
|
|
||||||
key: input.key,
|
|
||||||
scope,
|
|
||||||
kind: input.kind,
|
|
||||||
requirement: input.requirement,
|
|
||||||
portability: input.portability,
|
|
||||||
hasDefault: input.defaultValue !== null && input.defaultValue.length > 0,
|
|
||||||
description: input.description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSecret(secret: CompanySecret): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id: secret.id,
|
|
||||||
name: secret.name,
|
|
||||||
key: secret.key,
|
|
||||||
provider: secret.provider,
|
|
||||||
status: secret.status,
|
|
||||||
managedMode: secret.managedMode,
|
|
||||||
latestVersion: secret.latestVersion,
|
|
||||||
externalRef: secret.externalRef ? "yes" : "no",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function printProviderHealth(rows: SecretProviderHealth[], json: boolean): void {
|
|
||||||
if (json) {
|
|
||||||
printOutput(rows, { json: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (rows.length === 0) {
|
|
||||||
printOutput([], { json: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const row of rows) {
|
|
||||||
console.log(
|
|
||||||
formatInlineRecord({
|
|
||||||
id: row.provider,
|
|
||||||
status: row.status,
|
|
||||||
message: row.message,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const warning of row.warnings ?? []) {
|
|
||||||
console.log(pc.yellow(`warning=${warning}`));
|
|
||||||
}
|
|
||||||
const missingConfig = asStringArray(row.details?.missingConfig);
|
|
||||||
if (missingConfig.length > 0) {
|
|
||||||
console.log(pc.dim(`missingConfig=${missingConfig.join(",")}`));
|
|
||||||
}
|
|
||||||
const credentialSource = typeof row.details?.credentialSource === "string"
|
|
||||||
? row.details.credentialSource
|
|
||||||
: null;
|
|
||||||
if (credentialSource) {
|
|
||||||
console.log(pc.dim(`credentialSource=${credentialSource}`));
|
|
||||||
}
|
|
||||||
const detectedCredentialSources = asStringArray(row.details?.detectedCredentialSources);
|
|
||||||
if (detectedCredentialSources.length > 0) {
|
|
||||||
console.log(pc.dim(`detectedCredentialSources=${detectedCredentialSources.join(",")}`));
|
|
||||||
}
|
|
||||||
for (const guidance of row.backupGuidance ?? []) {
|
|
||||||
console.log(pc.dim(`backup=${guidance}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStringArray(value: unknown): string[] {
|
|
||||||
return Array.isArray(value)
|
|
||||||
? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<void> {
|
|
||||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
||||||
const companyId = ctx.companyId!;
|
|
||||||
const agents = (await ctx.api.get<Agent[]>(`/api/companies/${companyId}/agents`)) ?? [];
|
|
||||||
const secrets = (await ctx.api.get<CompanySecret[]>(`/api/companies/${companyId}/secrets`)) ?? [];
|
|
||||||
const candidates = collectInlineSecretMigrationCandidates(agents, secrets);
|
|
||||||
|
|
||||||
if (!opts.apply) {
|
|
||||||
printOutput(
|
|
||||||
{
|
|
||||||
apply: false,
|
|
||||||
agentsToUpdate: new Set(candidates.map((candidate) => candidate.agentId)).size,
|
|
||||||
secretsToCreate: candidates.filter((candidate) => !candidate.existingSecretId).length,
|
|
||||||
secretsToRotate: candidates.filter((candidate) => candidate.existingSecretId).length,
|
|
||||||
candidates,
|
|
||||||
},
|
|
||||||
{ json: ctx.json },
|
|
||||||
);
|
|
||||||
if (!ctx.json) {
|
|
||||||
console.log(pc.dim("Re-run with --apply to create/rotate secrets and update agent env bindings."));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdOrRotated = new Map<string, string>();
|
|
||||||
let createdSecrets = 0;
|
|
||||||
let rotatedSecrets = 0;
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const agent = agents.find((row) => row.id === candidate.agentId);
|
|
||||||
const env = asRecord(agent?.adapterConfig.env);
|
|
||||||
const value = env ? toPlainEnvValue(env[candidate.envKey]) : null;
|
|
||||||
if (!value) continue;
|
|
||||||
|
|
||||||
if (candidate.existingSecretId) {
|
|
||||||
await ctx.api.post(`/api/secrets/${candidate.existingSecretId}/rotate`, { value });
|
|
||||||
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, candidate.existingSecretId);
|
|
||||||
rotatedSecrets += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${companyId}/secrets`, {
|
|
||||||
name: candidate.secretName,
|
|
||||||
provider: "local_encrypted",
|
|
||||||
value,
|
|
||||||
description: `Migrated from agent ${candidate.agentId} env ${candidate.envKey}`,
|
|
||||||
});
|
|
||||||
if (!created) throw new Error(`Secret create returned no data for ${candidate.secretName}`);
|
|
||||||
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, created.id);
|
|
||||||
createdSecrets += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedAgents = 0;
|
|
||||||
for (const agent of agents) {
|
|
||||||
const env = asRecord(agent.adapterConfig.env);
|
|
||||||
if (!env) continue;
|
|
||||||
const secretIdByEnvKey = new Map<string, string>();
|
|
||||||
for (const [key] of Object.entries(env)) {
|
|
||||||
const secretId = createdOrRotated.get(`${agent.id}:${key}`);
|
|
||||||
if (secretId) secretIdByEnvKey.set(key, secretId);
|
|
||||||
}
|
|
||||||
if (secretIdByEnvKey.size === 0) continue;
|
|
||||||
const adapterConfig = {
|
|
||||||
...agent.adapterConfig,
|
|
||||||
env: buildMigratedAgentEnv(env, secretIdByEnvKey),
|
|
||||||
};
|
|
||||||
await ctx.api.patch(`/api/agents/${agent.id}`, {
|
|
||||||
adapterConfig,
|
|
||||||
replaceAdapterConfig: true,
|
|
||||||
});
|
|
||||||
updatedAgents += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
printOutput(
|
|
||||||
{
|
|
||||||
apply: true,
|
|
||||||
updatedAgents,
|
|
||||||
createdSecrets,
|
|
||||||
rotatedSecrets,
|
|
||||||
},
|
|
||||||
{ json: ctx.json },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSecretCommands(program: Command): void {
|
|
||||||
const secrets = program.command("secrets").description("Secret declaration and provider operations");
|
|
||||||
|
|
||||||
addCommonClientOptions(
|
|
||||||
secrets
|
|
||||||
.command("list")
|
|
||||||
.description("List secret metadata for a company")
|
|
||||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
||||||
.action(async (opts: SecretListOptions) => {
|
|
||||||
try {
|
|
||||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
||||||
const rows = (await ctx.api.get<CompanySecret[]>(`/api/companies/${ctx.companyId}/secrets`)) ?? [];
|
|
||||||
printOutput(ctx.json ? rows : rows.map(renderSecret), { json: ctx.json });
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
addCommonClientOptions(
|
|
||||||
secrets
|
|
||||||
.command("declarations")
|
|
||||||
.description("List portable env declarations emitted by company export")
|
|
||||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
||||||
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents,projects")
|
|
||||||
.option("--kind <kind>", "Filter declarations: all | secret | plain", "all")
|
|
||||||
.action(async (opts: SecretDeclarationsOptions) => {
|
|
||||||
try {
|
|
||||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
||||||
const kind = opts.kind ?? "all";
|
|
||||||
if (!["all", "secret", "plain"].includes(kind)) {
|
|
||||||
throw new Error("Invalid --kind value. Use: all, secret, plain");
|
|
||||||
}
|
|
||||||
const preview = await ctx.api.post<CompanyPortabilityExportPreviewResult>(
|
|
||||||
`/api/companies/${ctx.companyId}/exports/preview`,
|
|
||||||
{ include: parseSecretsInclude(opts.include) },
|
|
||||||
);
|
|
||||||
const declarations = (preview?.manifest.envInputs ?? [])
|
|
||||||
.filter((entry) => kind === "all" || entry.kind === kind);
|
|
||||||
printOutput(ctx.json ? declarations : declarations.map(renderDeclaration), { json: ctx.json });
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
addCommonClientOptions(
|
|
||||||
secrets
|
|
||||||
.command("create")
|
|
||||||
.description("Create a Paperclip-managed secret")
|
|
||||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
||||||
.requiredOption("--name <name>", "Secret display name")
|
|
||||||
.option("--key <key>", "Portable secret key")
|
|
||||||
.option("--provider <provider>", "Secret provider id")
|
|
||||||
.option("--value <value>", "Secret value")
|
|
||||||
.option("--value-env <name>", "Read secret value from an environment variable")
|
|
||||||
.option("--description <text>", "Description")
|
|
||||||
.action(async (opts: SecretCreateOptions) => {
|
|
||||||
try {
|
|
||||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
||||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
|
||||||
name: opts.name,
|
|
||||||
key: opts.key,
|
|
||||||
provider: opts.provider,
|
|
||||||
value: readValueFromOptions(opts),
|
|
||||||
description: opts.description,
|
|
||||||
});
|
|
||||||
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
addCommonClientOptions(
|
|
||||||
secrets
|
|
||||||
.command("link")
|
|
||||||
.description("Link an external provider-owned secret without storing its value in Paperclip")
|
|
||||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
||||||
.requiredOption("--name <name>", "Secret display name")
|
|
||||||
.requiredOption("--provider <provider>", "Secret provider id")
|
|
||||||
.requiredOption("--external-ref <ref>", "Provider secret ARN/name/path/reference")
|
|
||||||
.option("--key <key>", "Portable secret key")
|
|
||||||
.option("--provider-version-ref <ref>", "Provider version id or label")
|
|
||||||
.option("--description <text>", "Description")
|
|
||||||
.action(async (opts: SecretLinkOptions) => {
|
|
||||||
try {
|
|
||||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
||||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
|
||||||
name: opts.name,
|
|
||||||
key: opts.key,
|
|
||||||
provider: opts.provider,
|
|
||||||
managedMode: "external_reference",
|
|
||||||
externalRef: opts.externalRef,
|
|
||||||
providerVersionRef: opts.providerVersionRef,
|
|
||||||
description: opts.description,
|
|
||||||
});
|
|
||||||
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
addCommonClientOptions(
|
|
||||||
secrets
|
|
||||||
.command("doctor")
|
|
||||||
.description("Run secret provider health checks through the Paperclip API")
|
|
||||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
||||||
.action(async (opts: SecretDoctorOptions) => {
|
|
||||||
try {
|
|
||||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
||||||
const health = await ctx.api.get<SecretProviderHealthResponse>(
|
|
||||||
`/api/companies/${ctx.companyId}/secret-providers/health`,
|
|
||||||
);
|
|
||||||
printProviderHealth(health?.providers ?? [], ctx.json);
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
addCommonClientOptions(
|
|
||||||
secrets
|
|
||||||
.command("providers")
|
|
||||||
.description("List configured secret provider descriptors")
|
|
||||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
||||||
.action(async (opts: SecretDoctorOptions) => {
|
|
||||||
try {
|
|
||||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
||||||
const rows = (await ctx.api.get<SecretProviderDescriptor[]>(
|
|
||||||
`/api/companies/${ctx.companyId}/secret-providers`,
|
|
||||||
)) ?? [];
|
|
||||||
printOutput(rows, { json: ctx.json });
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
addCommonClientOptions(
|
|
||||||
secrets
|
|
||||||
.command("migrate-inline-env")
|
|
||||||
.description("Migrate inline sensitive agent env values into secret references")
|
|
||||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
||||||
.option("--apply", "Persist changes; default is a dry run", false)
|
|
||||||
.action(async (opts: SecretMigrateInlineEnvOptions) => {
|
|
||||||
try {
|
|
||||||
await migrateInlineEnv(opts);
|
|
||||||
} catch (err) {
|
|
||||||
handleCommandError(err);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,6 @@ function defaultConfig(): PaperclipConfig {
|
|||||||
server: {
|
server: {
|
||||||
deploymentMode: "local_trusted",
|
deploymentMode: "local_trusted",
|
||||||
exposure: "private",
|
exposure: "private",
|
||||||
bind: "loopback",
|
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 3100,
|
port: 3100,
|
||||||
allowedHostnames: [],
|
allowedHostnames: [],
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
|
|||||||
const result = await runDatabaseBackup({
|
const result = await runDatabaseBackup({
|
||||||
connectionString: connection.value,
|
connectionString: connection.value,
|
||||||
backupDir,
|
backupDir,
|
||||||
retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 },
|
retentionDays,
|
||||||
filenamePrefix,
|
filenamePrefix,
|
||||||
});
|
});
|
||||||
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
|
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import type { Command } from "commander";
|
|
||||||
import * as p from "@clack/prompts";
|
|
||||||
import pc from "picocolors";
|
|
||||||
import {
|
|
||||||
buildSshEnvLabFixtureConfig,
|
|
||||||
getSshEnvLabSupport,
|
|
||||||
readSshEnvLabFixtureStatus,
|
|
||||||
startSshEnvLabFixture,
|
|
||||||
stopSshEnvLabFixture,
|
|
||||||
} from "@paperclipai/adapter-utils/ssh";
|
|
||||||
import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js";
|
|
||||||
|
|
||||||
export function resolveEnvLabSshStatePath(instanceId?: string): string {
|
|
||||||
const resolvedInstanceId = resolvePaperclipInstanceId(instanceId);
|
|
||||||
return path.resolve(
|
|
||||||
resolvePaperclipInstanceRoot(resolvedInstanceId),
|
|
||||||
"env-lab",
|
|
||||||
"ssh-fixture",
|
|
||||||
"state.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function printJson(value: unknown) {
|
|
||||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeFixture(state: {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
workspaceDir: string;
|
|
||||||
sshdLogPath: string;
|
|
||||||
}) {
|
|
||||||
p.log.message(`Host: ${pc.cyan(state.host)}:${pc.cyan(String(state.port))}`);
|
|
||||||
p.log.message(`User: ${pc.cyan(state.username)}`);
|
|
||||||
p.log.message(`Workspace: ${pc.cyan(state.workspaceDir)}`);
|
|
||||||
p.log.message(`Log: ${pc.dim(state.sshdLogPath)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function collectEnvLabDoctorStatus(opts: { instance?: string }) {
|
|
||||||
const statePath = resolveEnvLabSshStatePath(opts.instance);
|
|
||||||
const [sshSupport, sshStatus] = await Promise.all([
|
|
||||||
getSshEnvLabSupport(),
|
|
||||||
readSshEnvLabFixtureStatus(statePath),
|
|
||||||
]);
|
|
||||||
const environment = sshStatus.state ? await buildSshEnvLabFixtureConfig(sshStatus.state) : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
statePath,
|
|
||||||
ssh: {
|
|
||||||
supported: sshSupport.supported,
|
|
||||||
reason: sshSupport.reason,
|
|
||||||
running: sshStatus.running,
|
|
||||||
state: sshStatus.state,
|
|
||||||
environment,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function envLabUpCommand(opts: { instance?: string; json?: boolean }) {
|
|
||||||
const statePath = resolveEnvLabSshStatePath(opts.instance);
|
|
||||||
const state = await startSshEnvLabFixture({ statePath });
|
|
||||||
const environment = await buildSshEnvLabFixtureConfig(state);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson({ state, environment });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.log.success("SSH env-lab fixture is running.");
|
|
||||||
summarizeFixture(state);
|
|
||||||
p.log.message(`State: ${pc.dim(statePath)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function envLabStatusCommand(opts: { instance?: string; json?: boolean }) {
|
|
||||||
const statePath = resolveEnvLabSshStatePath(opts.instance);
|
|
||||||
const status = await readSshEnvLabFixtureStatus(statePath);
|
|
||||||
const environment = status.state ? await buildSshEnvLabFixtureConfig(status.state) : null;
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson({ ...status, environment, statePath });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.state || !status.running) {
|
|
||||||
p.log.info(`SSH env-lab fixture is not running (${pc.dim(statePath)}).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.log.success("SSH env-lab fixture is running.");
|
|
||||||
summarizeFixture(status.state);
|
|
||||||
p.log.message(`State: ${pc.dim(statePath)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function envLabDownCommand(opts: { instance?: string; json?: boolean }) {
|
|
||||||
const statePath = resolveEnvLabSshStatePath(opts.instance);
|
|
||||||
const stopped = await stopSshEnvLabFixture(statePath);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson({ stopped, statePath });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stopped) {
|
|
||||||
p.log.info(`No SSH env-lab fixture was running (${pc.dim(statePath)}).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.log.success("SSH env-lab fixture stopped.");
|
|
||||||
p.log.message(`State: ${pc.dim(statePath)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function envLabDoctorCommand(opts: { instance?: string; json?: boolean }) {
|
|
||||||
const status = await collectEnvLabDoctorStatus(opts);
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
printJson(status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.ssh.supported) {
|
|
||||||
p.log.success("SSH fixture prerequisites are installed.");
|
|
||||||
} else {
|
|
||||||
p.log.warn(`SSH fixture prerequisites are incomplete: ${status.ssh.reason ?? "unknown reason"}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.ssh.state && status.ssh.running) {
|
|
||||||
p.log.success("SSH env-lab fixture is running.");
|
|
||||||
summarizeFixture(status.ssh.state);
|
|
||||||
p.log.message(`Private key: ${pc.dim(status.ssh.state.clientPrivateKeyPath)}`);
|
|
||||||
p.log.message(`Known hosts: ${pc.dim(status.ssh.state.knownHostsPath)}`);
|
|
||||||
} else if (status.ssh.state) {
|
|
||||||
p.log.warn("SSH env-lab fixture state exists, but the process is not running.");
|
|
||||||
p.log.message(`State: ${pc.dim(status.statePath)}`);
|
|
||||||
} else {
|
|
||||||
p.log.info("SSH env-lab fixture is not running.");
|
|
||||||
p.log.message(`State: ${pc.dim(status.statePath)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
p.log.message(`Cleanup: ${pc.dim("pnpm paperclipai env-lab down")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerEnvLabCommands(program: Command) {
|
|
||||||
const envLab = program.command("env-lab").description("Deterministic local environment fixtures");
|
|
||||||
|
|
||||||
envLab
|
|
||||||
.command("up")
|
|
||||||
.description("Start the default SSH env-lab fixture")
|
|
||||||
.option("-i, --instance <id>", "Paperclip instance id (default: current/default)")
|
|
||||||
.option("--json", "Print machine-readable fixture details")
|
|
||||||
.action(envLabUpCommand);
|
|
||||||
|
|
||||||
envLab
|
|
||||||
.command("status")
|
|
||||||
.description("Show the current SSH env-lab fixture state")
|
|
||||||
.option("-i, --instance <id>", "Paperclip instance id (default: current/default)")
|
|
||||||
.option("--json", "Print machine-readable fixture details")
|
|
||||||
.action(envLabStatusCommand);
|
|
||||||
|
|
||||||
envLab
|
|
||||||
.command("down")
|
|
||||||
.description("Stop the default SSH env-lab fixture")
|
|
||||||
.option("-i, --instance <id>", "Paperclip instance id (default: current/default)")
|
|
||||||
.option("--json", "Print machine-readable stop details")
|
|
||||||
.action(envLabDownCommand);
|
|
||||||
|
|
||||||
envLab
|
|
||||||
.command("doctor")
|
|
||||||
.description("Check SSH fixture prerequisites and current status")
|
|
||||||
.option("-i, --instance <id>", "Paperclip instance id (default: current/default)")
|
|
||||||
.option("--json", "Print machine-readable diagnostic details")
|
|
||||||
.action(envLabDoctorCommand);
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,10 @@ import path from "node:path";
|
|||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import {
|
import {
|
||||||
AUTH_BASE_URL_MODES,
|
AUTH_BASE_URL_MODES,
|
||||||
BIND_MODES,
|
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
DEPLOYMENT_MODES,
|
DEPLOYMENT_MODES,
|
||||||
SECRET_PROVIDERS,
|
SECRET_PROVIDERS,
|
||||||
STORAGE_PROVIDERS,
|
STORAGE_PROVIDERS,
|
||||||
inferBindModeFromHost,
|
|
||||||
resolveRuntimeBind,
|
|
||||||
type BindMode,
|
|
||||||
type AuthBaseUrlMode,
|
type AuthBaseUrlMode,
|
||||||
type DeploymentExposure,
|
type DeploymentExposure,
|
||||||
type DeploymentMode,
|
type DeploymentMode,
|
||||||
@@ -27,7 +23,6 @@ import { promptLogging } from "../prompts/logging.js";
|
|||||||
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
||||||
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||||
import { promptServer } from "../prompts/server.js";
|
import { promptServer } from "../prompts/server.js";
|
||||||
import { buildPresetServerConfig } from "../config/server-bind.js";
|
|
||||||
import {
|
import {
|
||||||
describeLocalInstancePaths,
|
describeLocalInstancePaths,
|
||||||
expandHomePrefix,
|
expandHomePrefix,
|
||||||
@@ -51,14 +46,10 @@ type OnboardOptions = {
|
|||||||
run?: boolean;
|
run?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
invokedByRun?: boolean;
|
invokedByRun?: boolean;
|
||||||
bind?: BindMode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
||||||
|
|
||||||
const TAILNET_BIND_WARNING =
|
|
||||||
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
|
|
||||||
|
|
||||||
const ONBOARD_ENV_KEYS = [
|
const ONBOARD_ENV_KEYS = [
|
||||||
"PAPERCLIP_PUBLIC_URL",
|
"PAPERCLIP_PUBLIC_URL",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
@@ -68,9 +59,6 @@ const ONBOARD_ENV_KEYS = [
|
|||||||
"PAPERCLIP_DB_BACKUP_DIR",
|
"PAPERCLIP_DB_BACKUP_DIR",
|
||||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
"PAPERCLIP_DEPLOYMENT_MODE",
|
||||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||||
"PAPERCLIP_BIND",
|
|
||||||
"PAPERCLIP_BIND_HOST",
|
|
||||||
"PAPERCLIP_TAILNET_BIND_HOST",
|
|
||||||
"HOST",
|
"HOST",
|
||||||
"PORT",
|
"PORT",
|
||||||
"SERVE_UI",
|
"SERVE_UI",
|
||||||
@@ -116,62 +104,29 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null {
|
|||||||
return path.resolve(expandHomePrefix(rawValue.trim()));
|
return path.resolve(expandHomePrefix(rawValue.trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeServerBinding(server: Pick<PaperclipConfig["server"], "bind" | "customBindHost" | "host" | "port">): string {
|
function quickstartDefaultsFromEnv(): {
|
||||||
const bind = server.bind ?? inferBindModeFromHost(server.host);
|
|
||||||
const detail =
|
|
||||||
bind === "custom"
|
|
||||||
? server.customBindHost ?? server.host
|
|
||||||
: bind === "tailnet"
|
|
||||||
? "detected tailscale address"
|
|
||||||
: server.host;
|
|
||||||
return `${bind}${detail ? ` (${detail})` : ""}:${server.port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
|
|
||||||
defaults: OnboardDefaults;
|
defaults: OnboardDefaults;
|
||||||
usedEnvKeys: string[];
|
usedEnvKeys: string[];
|
||||||
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
||||||
} {
|
} {
|
||||||
const preferTrustedLocal = opts?.preferTrustedLocal ?? false;
|
|
||||||
const instanceId = resolvePaperclipInstanceId();
|
const instanceId = resolvePaperclipInstanceId();
|
||||||
const defaultStorage = defaultStorageConfig();
|
const defaultStorage = defaultStorageConfig();
|
||||||
const defaultSecrets = defaultSecretsConfig();
|
const defaultSecrets = defaultSecretsConfig();
|
||||||
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
||||||
const publicUrl = preferTrustedLocal
|
const publicUrl =
|
||||||
? undefined
|
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
||||||
: (
|
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
||||||
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
process.env.BETTER_AUTH_URL?.trim() ||
|
||||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
||||||
process.env.BETTER_AUTH_URL?.trim() ||
|
undefined;
|
||||||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
const deploymentMode =
|
||||||
undefined
|
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
|
||||||
);
|
|
||||||
const deploymentMode = preferTrustedLocal
|
|
||||||
? "local_trusted"
|
|
||||||
: (parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted");
|
|
||||||
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
||||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
);
|
);
|
||||||
const deploymentExposure =
|
const deploymentExposure =
|
||||||
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
|
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
|
||||||
const bindFromEnv = parseEnumFromEnv<BindMode>(process.env.PAPERCLIP_BIND, BIND_MODES);
|
|
||||||
const customBindHostFromEnv = process.env.PAPERCLIP_BIND_HOST?.trim() || undefined;
|
|
||||||
const hostFromEnv = process.env.HOST?.trim() || undefined;
|
|
||||||
const configuredBindHost = customBindHostFromEnv ?? hostFromEnv;
|
|
||||||
const bind = preferTrustedLocal
|
|
||||||
? "loopback"
|
|
||||||
: (
|
|
||||||
deploymentMode === "local_trusted"
|
|
||||||
? "loopback"
|
|
||||||
: (bindFromEnv ?? (configuredBindHost ? inferBindModeFromHost(configuredBindHost) : "lan"))
|
|
||||||
);
|
|
||||||
const resolvedBind = resolveRuntimeBind({
|
|
||||||
bind,
|
|
||||||
host: hostFromEnv ?? (bind === "loopback" ? "127.0.0.1" : "0.0.0.0"),
|
|
||||||
customBindHost: customBindHostFromEnv,
|
|
||||||
tailnetBindHost: process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(),
|
|
||||||
});
|
|
||||||
const authPublicBaseUrl = publicUrl;
|
const authPublicBaseUrl = publicUrl;
|
||||||
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
||||||
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
||||||
@@ -228,9 +183,7 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
|
|||||||
server: {
|
server: {
|
||||||
deploymentMode,
|
deploymentMode,
|
||||||
exposure: deploymentExposure,
|
exposure: deploymentExposure,
|
||||||
bind: resolvedBind.bind,
|
host: process.env.HOST ?? "127.0.0.1",
|
||||||
...(resolvedBind.customBindHost ? { customBindHost: resolvedBind.customBindHost } : {}),
|
|
||||||
host: resolvedBind.host,
|
|
||||||
port: Number(process.env.PORT) || 3100,
|
port: Number(process.env.PORT) || 3100,
|
||||||
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
|
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
|
||||||
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
||||||
@@ -267,49 +220,12 @@ function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
|
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
|
||||||
if (preferTrustedLocal) {
|
|
||||||
const forcedLocalReason = "Ignored because --yes quickstart forces trusted local loopback defaults";
|
|
||||||
for (const key of [
|
|
||||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
|
||||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
|
||||||
"PAPERCLIP_BIND",
|
|
||||||
"PAPERCLIP_BIND_HOST",
|
|
||||||
"HOST",
|
|
||||||
"PAPERCLIP_AUTH_BASE_URL_MODE",
|
|
||||||
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
|
|
||||||
"PAPERCLIP_PUBLIC_URL",
|
|
||||||
"BETTER_AUTH_URL",
|
|
||||||
"BETTER_AUTH_BASE_URL",
|
|
||||||
] as const) {
|
|
||||||
if (process.env[key] !== undefined) {
|
|
||||||
ignoredEnvKeys.push({ key, reason: forcedLocalReason });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
|
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
|
||||||
ignoredEnvKeys.push({
|
ignoredEnvKeys.push({
|
||||||
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||||
reason: "Ignored because deployment mode local_trusted always forces private exposure",
|
reason: "Ignored because deployment mode local_trusted always forces private exposure",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND !== undefined) {
|
|
||||||
ignoredEnvKeys.push({
|
|
||||||
key: "PAPERCLIP_BIND",
|
|
||||||
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND_HOST !== undefined) {
|
|
||||||
ignoredEnvKeys.push({
|
|
||||||
key: "PAPERCLIP_BIND_HOST",
|
|
||||||
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (deploymentMode === "local_trusted" && process.env.HOST !== undefined) {
|
|
||||||
ignoredEnvKeys.push({
|
|
||||||
key: "HOST",
|
|
||||||
reason: "Ignored because deployment mode local_trusted always uses loopback reachability",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
|
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
|
||||||
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
||||||
@@ -323,10 +239,6 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) {
|
|
||||||
throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
printPaperclipCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
@@ -381,7 +293,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
`Database: ${existingConfig.database.mode}`,
|
`Database: ${existingConfig.database.mode}`,
|
||||||
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
|
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
|
||||||
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
|
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
|
||||||
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${describeServerBinding(existingConfig.server)}`,
|
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
|
||||||
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
||||||
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
|
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
|
||||||
`Storage: ${existingConfig.storage.provider}`,
|
`Storage: ${existingConfig.storage.provider}`,
|
||||||
@@ -424,13 +336,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
|
|
||||||
let setupMode: SetupMode = "quickstart";
|
let setupMode: SetupMode = "quickstart";
|
||||||
if (opts.yes) {
|
if (opts.yes) {
|
||||||
p.log.message(
|
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
|
||||||
pc.dim(
|
|
||||||
opts.bind
|
|
||||||
? `\`--yes\` enabled: using Quickstart defaults with bind=${opts.bind}.`
|
|
||||||
: "`--yes` enabled: using Quickstart defaults.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const setupModeChoice = await p.select({
|
const setupModeChoice = await p.select({
|
||||||
message: "Choose setup path",
|
message: "Choose setup path",
|
||||||
@@ -459,9 +365,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
if (tc) trackInstallStarted(tc);
|
if (tc) trackInstallStarted(tc);
|
||||||
|
|
||||||
let llm: PaperclipConfig["llm"] | undefined;
|
let llm: PaperclipConfig["llm"] | undefined;
|
||||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({
|
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||||
preferTrustedLocal: opts.yes === true && !opts.bind,
|
|
||||||
});
|
|
||||||
let {
|
let {
|
||||||
database,
|
database,
|
||||||
logging,
|
logging,
|
||||||
@@ -471,19 +375,6 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
secrets,
|
secrets,
|
||||||
} = derivedDefaults;
|
} = derivedDefaults;
|
||||||
|
|
||||||
if (opts.bind === "loopback" || opts.bind === "lan" || opts.bind === "tailnet") {
|
|
||||||
const preset = buildPresetServerConfig(opts.bind, {
|
|
||||||
port: server.port,
|
|
||||||
allowedHostnames: server.allowedHostnames,
|
|
||||||
serveUi: server.serveUi,
|
|
||||||
});
|
|
||||||
server = preset.server;
|
|
||||||
auth = preset.auth;
|
|
||||||
if (opts.bind === "tailnet" && server.host === "127.0.0.1") {
|
|
||||||
p.log.warn(TAILNET_BIND_WARNING);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setupMode === "advanced") {
|
if (setupMode === "advanced") {
|
||||||
p.log.step(pc.bold("Database"));
|
p.log.step(pc.bold("Database"));
|
||||||
database = await promptDatabase(database);
|
database = await promptDatabase(database);
|
||||||
@@ -571,13 +462,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
p.log.step(pc.bold("Quickstart"));
|
p.log.step(pc.bold("Quickstart"));
|
||||||
p.log.message(
|
p.log.message(pc.dim("Using quickstart defaults."));
|
||||||
pc.dim(
|
|
||||||
opts.bind
|
|
||||||
? `Using quickstart defaults with bind=${opts.bind}.`
|
|
||||||
: `Using quickstart defaults: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (usedEnvKeys.length > 0) {
|
if (usedEnvKeys.length > 0) {
|
||||||
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
||||||
} else {
|
} else {
|
||||||
@@ -636,7 +521,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
`Database: ${database.mode}`,
|
`Database: ${database.mode}`,
|
||||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||||
`Logging: ${logging.mode} -> ${logging.logDir}`,
|
`Logging: ${logging.mode} -> ${logging.logDir}`,
|
||||||
`Server: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}`,
|
`Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`,
|
||||||
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
||||||
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
|
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
|
||||||
`Storage: ${storage.provider}`,
|
`Storage: ${storage.provider}`,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
@@ -22,7 +21,6 @@ interface RunOptions {
|
|||||||
instance?: string;
|
instance?: string;
|
||||||
repair?: boolean;
|
repair?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
bind?: "loopback" | "lan" | "tailnet";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StartedServer {
|
interface StartedServer {
|
||||||
@@ -59,7 +57,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.log.step("No config found. Starting onboarding...");
|
p.log.step("No config found. Starting onboarding...");
|
||||||
await onboard({ config: configPath, invokedByRun: true, bind: opts.bind });
|
await onboard({ config: configPath, invokedByRun: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
p.log.step("Running doctor checks...");
|
p.log.step("Running doctor checks...");
|
||||||
@@ -148,35 +146,11 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDevWorkspaceBuildDeps(projectRoot: string): void {
|
|
||||||
const buildScript = path.resolve(projectRoot, "scripts/ensure-plugin-build-deps.mjs");
|
|
||||||
if (!fs.existsSync(buildScript)) return;
|
|
||||||
|
|
||||||
const result = spawnSync(process.execPath, [buildScript], {
|
|
||||||
cwd: projectRoot,
|
|
||||||
stdio: "inherit",
|
|
||||||
timeout: 120_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to prepare workspace build artifacts before starting the Paperclip dev server.\n${formatError(result.error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((result.status ?? 1) !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
"Failed to prepare workspace build artifacts before starting the Paperclip dev server.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importServerEntry(): Promise<StartedServer> {
|
async function importServerEntry(): Promise<StartedServer> {
|
||||||
// Dev mode: try local workspace path (monorepo with tsx)
|
// Dev mode: try local workspace path (monorepo with tsx)
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||||
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
||||||
if (fs.existsSync(devEntry)) {
|
if (fs.existsSync(devEntry)) {
|
||||||
ensureDevWorkspaceBuildDeps(projectRoot);
|
|
||||||
maybeEnableUiDevMiddleware(devEntry);
|
maybeEnableUiDevMiddleware(devEntry);
|
||||||
const mod = await import(pathToFileURL(devEntry).href);
|
const mod = await import(pathToFileURL(devEntry).href);
|
||||||
return await startServerFromModule(mod, devEntry);
|
return await startServerFromModule(mod, devEntry);
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ function nonEmpty(value: string | null | undefined): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
const value = hostname.trim().toLowerCase();
|
||||||
|
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||||
const trimmed = rawValue.trim().toLowerCase();
|
const trimmed = rawValue.trim().toLowerCase();
|
||||||
const normalized = trimmed
|
const normalized = trimmed
|
||||||
@@ -163,8 +168,7 @@ export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): s
|
|||||||
if (!rawUrl) return undefined;
|
if (!rawUrl) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(rawUrl);
|
const parsed = new URL(rawUrl);
|
||||||
// The URL API normalizes default ports like :80/:443 to "", so treat them as stable URLs.
|
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||||
if (!parsed.port) return rawUrl;
|
|
||||||
parsed.port = String(port);
|
parsed.port = String(port);
|
||||||
return parsed.toString();
|
return parsed.toString();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -210,8 +214,6 @@ export function buildWorktreeConfig(input: {
|
|||||||
server: {
|
server: {
|
||||||
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
||||||
exposure: source?.server.exposure ?? "private",
|
exposure: source?.server.exposure ?? "private",
|
||||||
...(source?.server.bind ? { bind: source.server.bind } : {}),
|
|
||||||
...(source?.server.customBindHost ? { customBindHost: source.server.customBindHost } : {}),
|
|
||||||
host: source?.server.host ?? "127.0.0.1",
|
host: source?.server.host ?? "127.0.0.1",
|
||||||
port: serverPort,
|
port: serverPort,
|
||||||
allowedHostnames: source?.server.allowedHostnames ?? [],
|
allowedHostnames: source?.server.allowedHostnames ?? [],
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ import {
|
|||||||
issues,
|
issues,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
routines,
|
|
||||||
routineTriggers,
|
|
||||||
runDatabaseBackup,
|
runDatabaseBackup,
|
||||||
runDatabaseRestore,
|
runDatabaseRestore,
|
||||||
createEmbeddedPostgresLogBuffer,
|
createEmbeddedPostgresLogBuffer,
|
||||||
@@ -93,7 +91,6 @@ type WorktreeInitOptions = {
|
|||||||
dbPort?: number;
|
dbPort?: number;
|
||||||
seed?: boolean;
|
seed?: boolean;
|
||||||
seedMode?: string;
|
seedMode?: string;
|
||||||
preserveLiveWork?: boolean;
|
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,23 +124,10 @@ type WorktreeReseedOptions = {
|
|||||||
fromDataDir?: string;
|
fromDataDir?: string;
|
||||||
fromInstance?: string;
|
fromInstance?: string;
|
||||||
seedMode?: string;
|
seedMode?: string;
|
||||||
preserveLiveWork?: boolean;
|
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
allowLiveTarget?: boolean;
|
allowLiveTarget?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorktreeRepairOptions = {
|
|
||||||
branch?: string;
|
|
||||||
home?: string;
|
|
||||||
fromConfig?: string;
|
|
||||||
fromDataDir?: string;
|
|
||||||
fromInstance?: string;
|
|
||||||
seedMode?: string;
|
|
||||||
preserveLiveWork?: boolean;
|
|
||||||
noSeed?: boolean;
|
|
||||||
allowLiveTarget?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EmbeddedPostgresInstance = {
|
type EmbeddedPostgresInstance = {
|
||||||
initialise(): Promise<void>;
|
initialise(): Promise<void>;
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
@@ -182,8 +166,6 @@ type CopiedGitHooksResult = {
|
|||||||
|
|
||||||
type SeedWorktreeDatabaseResult = {
|
type SeedWorktreeDatabaseResult = {
|
||||||
backupSummary: string;
|
backupSummary: string;
|
||||||
pausedScheduledRoutines: number;
|
|
||||||
executionQuarantine: SeededWorktreeExecutionQuarantineSummary;
|
|
||||||
reboundWorkspaces: Array<{
|
reboundWorkspaces: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
fromCwd: string;
|
fromCwd: string;
|
||||||
@@ -191,14 +173,6 @@ type SeedWorktreeDatabaseResult = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SeededWorktreeExecutionQuarantineSummary = {
|
|
||||||
disabledTimerHeartbeats: number;
|
|
||||||
resetRunningAgents: number;
|
|
||||||
quarantinedInProgressIssues: number;
|
|
||||||
unassignedTodoIssues: number;
|
|
||||||
unassignedReviewIssues: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function nonEmpty(value: string | null | undefined): string | null {
|
function nonEmpty(value: string | null | undefined): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
}
|
}
|
||||||
@@ -211,18 +185,6 @@ function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
|
|||||||
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
|
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSeededWorktreeExecutionQuarantineSummary(
|
|
||||||
summary: SeededWorktreeExecutionQuarantineSummary,
|
|
||||||
): string {
|
|
||||||
return [
|
|
||||||
`disabled timer heartbeats: ${summary.disabledTimerHeartbeats}`,
|
|
||||||
`reset running agents: ${summary.resetRunningAgents}`,
|
|
||||||
`quarantined in-progress issues: ${summary.quarantinedInProgressIssues}`,
|
|
||||||
`unassigned todo issues: ${summary.unassignedTodoIssues}`,
|
|
||||||
`unassigned review issues: ${summary.unassignedReviewIssues}`,
|
|
||||||
].join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKTREE_NAME_PREFIX = "paperclip-";
|
const WORKTREE_NAME_PREFIX = "paperclip-";
|
||||||
|
|
||||||
function resolveWorktreeMakeName(name: string): string {
|
function resolveWorktreeMakeName(name: string): string {
|
||||||
@@ -586,46 +548,6 @@ function detectGitBranchName(cwd: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateGitBranchName(cwd: string, branchName: string): string {
|
|
||||||
const value = nonEmpty(branchName);
|
|
||||||
if (!value) {
|
|
||||||
throw new Error("Branch name is required.");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
execFileSync("git", ["check-ref-format", "--branch", value], {
|
|
||||||
cwd,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Invalid branch name "${branchName}": ${extractExecSyncErrorMessage(error) ?? String(error)}`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrimaryGitWorktree(cwd: string): boolean {
|
|
||||||
const workspace = detectGitWorkspaceInfo(cwd);
|
|
||||||
return Boolean(workspace && workspace.gitDir === workspace.commonDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePrimaryGitRepoRoot(cwd: string): string {
|
|
||||||
const workspace = detectGitWorkspaceInfo(cwd);
|
|
||||||
if (!workspace) {
|
|
||||||
throw new Error("Current directory is not inside a git repository.");
|
|
||||||
}
|
|
||||||
if (workspace.gitDir === workspace.commonDir) {
|
|
||||||
return workspace.root;
|
|
||||||
}
|
|
||||||
return path.resolve(workspace.commonDir, "..");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRepairWorktreeDirName(branchName: string): string {
|
|
||||||
const normalized = branchName.trim()
|
|
||||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^[-._]+|[-._]+$/g, "");
|
|
||||||
return normalized || "worktree";
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||||
try {
|
try {
|
||||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
@@ -849,21 +771,6 @@ export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): Resol
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorktreeRepairSource(input: WorktreeRepairOptions): ResolvedWorktreeReseedSource {
|
|
||||||
const fromConfig = nonEmpty(input.fromConfig);
|
|
||||||
const fromDataDir = nonEmpty(input.fromDataDir);
|
|
||||||
const fromInstance = nonEmpty(input.fromInstance) ?? "default";
|
|
||||||
const configPath = resolveSourceConfigPath({
|
|
||||||
fromConfig: fromConfig ?? undefined,
|
|
||||||
fromDataDir: fromDataDir ?? undefined,
|
|
||||||
fromInstance,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
configPath,
|
|
||||||
label: configPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWorktreeReseedTargetPaths(input: {
|
export function resolveWorktreeReseedTargetPaths(input: {
|
||||||
configPath: string;
|
configPath: string;
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
@@ -885,105 +792,6 @@ export function resolveWorktreeReseedTargetPaths(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExistingGitWorktree(selector: string, cwd: string): MergeSourceChoice | null {
|
|
||||||
const trimmed = selector.trim();
|
|
||||||
if (trimmed.length === 0) return null;
|
|
||||||
|
|
||||||
const directPath = path.resolve(trimmed);
|
|
||||||
if (existsSync(directPath)) {
|
|
||||||
return {
|
|
||||||
worktree: directPath,
|
|
||||||
branch: null,
|
|
||||||
branchLabel: path.basename(directPath),
|
|
||||||
hasPaperclipConfig: existsSync(path.resolve(directPath, ".paperclip", "config.json")),
|
|
||||||
isCurrent: directPath === path.resolve(cwd),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return toMergeSourceChoices(cwd).find((choice) =>
|
|
||||||
choice.worktree === directPath
|
|
||||||
|| path.basename(choice.worktree) === trimmed
|
|
||||||
|| choice.branchLabel === trimmed
|
|
||||||
|| choice.branch === trimmed,
|
|
||||||
) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureRepairTargetWorktree(input: {
|
|
||||||
selector?: string;
|
|
||||||
seedMode: WorktreeSeedMode;
|
|
||||||
opts: WorktreeRepairOptions;
|
|
||||||
}): Promise<ResolvedWorktreeRepairTarget | null> {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const currentRoot = path.resolve(cwd);
|
|
||||||
const currentConfigPath = path.resolve(currentRoot, ".paperclip", "config.json");
|
|
||||||
|
|
||||||
if (!input.selector) {
|
|
||||||
if (isPrimaryGitWorktree(cwd)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
rootPath: currentRoot,
|
|
||||||
configPath: currentConfigPath,
|
|
||||||
label: path.basename(currentRoot),
|
|
||||||
branchName: detectGitBranchName(cwd),
|
|
||||||
created: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = resolveExistingGitWorktree(input.selector, cwd);
|
|
||||||
if (existing) {
|
|
||||||
return {
|
|
||||||
rootPath: existing.worktree,
|
|
||||||
configPath: path.resolve(existing.worktree, ".paperclip", "config.json"),
|
|
||||||
label: existing.branchLabel,
|
|
||||||
branchName: existing.branchLabel === "(detached)" ? null : existing.branchLabel,
|
|
||||||
created: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoRoot = resolvePrimaryGitRepoRoot(cwd);
|
|
||||||
const branchName = validateGitBranchName(repoRoot, input.selector);
|
|
||||||
const targetPath = path.resolve(
|
|
||||||
repoRoot,
|
|
||||||
".paperclip",
|
|
||||||
"worktrees",
|
|
||||||
resolveRepairWorktreeDirName(branchName),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existsSync(targetPath)) {
|
|
||||||
throw new Error(`Target path already exists but is not a registered git worktree: ${targetPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
||||||
|
|
||||||
const spinner = p.spinner();
|
|
||||||
spinner.start(`Creating git worktree for ${branchName}...`);
|
|
||||||
try {
|
|
||||||
execFileSync("git", resolveGitWorktreeAddArgs({
|
|
||||||
branchName,
|
|
||||||
targetPath,
|
|
||||||
branchExists: localBranchExists(repoRoot, branchName),
|
|
||||||
}), {
|
|
||||||
cwd: repoRoot,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
spinner.stop(`Created git worktree at ${targetPath}.`);
|
|
||||||
} catch (error) {
|
|
||||||
spinner.stop(pc.red("Failed to create git worktree."));
|
|
||||||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
installDependenciesBestEffort(targetPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
rootPath: targetPath,
|
|
||||||
configPath: path.resolve(targetPath, ".paperclip", "config.json"),
|
|
||||||
label: branchName,
|
|
||||||
branchName,
|
|
||||||
created: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||||
if (config.database.mode === "postgres") {
|
if (config.database.mode === "postgres") {
|
||||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||||
@@ -1114,163 +922,6 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pauseSeededScheduledRoutines(connectionString: string): Promise<number> {
|
|
||||||
const db = createDb(connectionString);
|
|
||||||
try {
|
|
||||||
const scheduledRoutineIds = await db
|
|
||||||
.selectDistinct({ routineId: routineTriggers.routineId })
|
|
||||||
.from(routineTriggers)
|
|
||||||
.where(and(eq(routineTriggers.kind, "schedule"), eq(routineTriggers.enabled, true)));
|
|
||||||
const idsToPause = scheduledRoutineIds
|
|
||||||
.map((row) => row.routineId)
|
|
||||||
.filter((value): value is string => Boolean(value));
|
|
||||||
|
|
||||||
if (idsToPause.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paused = await db
|
|
||||||
.update(routines)
|
|
||||||
.set({
|
|
||||||
status: "paused",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(and(inArray(routines.id, idsToPause), sql`${routines.status} <> 'paused'`, sql`${routines.status} <> 'archived'`))
|
|
||||||
.returning({ id: routines.id });
|
|
||||||
|
|
||||||
return paused.length;
|
|
||||||
} finally {
|
|
||||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY: SeededWorktreeExecutionQuarantineSummary = {
|
|
||||||
disabledTimerHeartbeats: 0,
|
|
||||||
resetRunningAgents: 0,
|
|
||||||
quarantinedInProgressIssues: 0,
|
|
||||||
unassignedTodoIssues: 0,
|
|
||||||
unassignedReviewIssues: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnabledValue(value: unknown): boolean {
|
|
||||||
return value === true || value === "true" || value === 1 || value === "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWorktreeRuntimeConfig(runtimeConfig: unknown): {
|
|
||||||
runtimeConfig: Record<string, unknown>;
|
|
||||||
disabledTimerHeartbeat: boolean;
|
|
||||||
changed: boolean;
|
|
||||||
} {
|
|
||||||
const nextRuntimeConfig = isRecord(runtimeConfig) ? { ...runtimeConfig } : {};
|
|
||||||
const heartbeat = isRecord(nextRuntimeConfig.heartbeat) ? { ...nextRuntimeConfig.heartbeat } : null;
|
|
||||||
if (!heartbeat) {
|
|
||||||
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const disabledTimerHeartbeat = isEnabledValue(heartbeat.enabled);
|
|
||||||
if (heartbeat.enabled !== false) {
|
|
||||||
heartbeat.enabled = false;
|
|
||||||
nextRuntimeConfig.heartbeat = heartbeat;
|
|
||||||
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat, changed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function quarantineSeededWorktreeExecutionState(
|
|
||||||
connectionString: string,
|
|
||||||
): Promise<SeededWorktreeExecutionQuarantineSummary> {
|
|
||||||
const db = createDb(connectionString);
|
|
||||||
const summary = { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY };
|
|
||||||
try {
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
const seededAgents = await tx
|
|
||||||
.select({
|
|
||||||
id: agents.id,
|
|
||||||
status: agents.status,
|
|
||||||
runtimeConfig: agents.runtimeConfig,
|
|
||||||
})
|
|
||||||
.from(agents);
|
|
||||||
|
|
||||||
for (const agent of seededAgents) {
|
|
||||||
const normalized = normalizeWorktreeRuntimeConfig(agent.runtimeConfig);
|
|
||||||
const nextStatus = agent.status === "running" ? "idle" : agent.status;
|
|
||||||
if (normalized.disabledTimerHeartbeat) {
|
|
||||||
summary.disabledTimerHeartbeats += 1;
|
|
||||||
}
|
|
||||||
if (agent.status === "running") {
|
|
||||||
summary.resetRunningAgents += 1;
|
|
||||||
}
|
|
||||||
if (normalized.changed || nextStatus !== agent.status) {
|
|
||||||
await tx
|
|
||||||
.update(agents)
|
|
||||||
.set({
|
|
||||||
runtimeConfig: normalized.runtimeConfig,
|
|
||||||
status: nextStatus,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(agents.id, agent.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const affectedIssues = await tx
|
|
||||||
.select({
|
|
||||||
id: issues.id,
|
|
||||||
companyId: issues.companyId,
|
|
||||||
status: issues.status,
|
|
||||||
})
|
|
||||||
.from(issues)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
sql`${issues.assigneeAgentId} is not null`,
|
|
||||||
sql`${issues.assigneeUserId} is null`,
|
|
||||||
inArray(issues.status, ["todo", "in_progress", "in_review"]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const issue of affectedIssues) {
|
|
||||||
const nextStatus = issue.status === "in_progress" ? "blocked" : issue.status;
|
|
||||||
await tx
|
|
||||||
.update(issues)
|
|
||||||
.set({
|
|
||||||
status: nextStatus,
|
|
||||||
assigneeAgentId: null,
|
|
||||||
checkoutRunId: null,
|
|
||||||
executionRunId: null,
|
|
||||||
executionAgentNameKey: null,
|
|
||||||
executionLockedAt: null,
|
|
||||||
executionWorkspaceId: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(issues.id, issue.id));
|
|
||||||
|
|
||||||
if (issue.status === "in_progress") {
|
|
||||||
summary.quarantinedInProgressIssues += 1;
|
|
||||||
await tx.insert(issueComments).values({
|
|
||||||
companyId: issue.companyId,
|
|
||||||
issueId: issue.id,
|
|
||||||
body:
|
|
||||||
"Quarantined during worktree seed so copied in-flight work does not auto-run in this isolated instance. " +
|
|
||||||
"Reassign or unblock here only if you intentionally want the worktree instance to own this task.",
|
|
||||||
});
|
|
||||||
} else if (issue.status === "todo") {
|
|
||||||
summary.unassignedTodoIssues += 1;
|
|
||||||
} else if (issue.status === "in_review") {
|
|
||||||
summary.unassignedReviewIssues += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return summary;
|
|
||||||
} finally {
|
|
||||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedWorktreeDatabase(input: {
|
async function seedWorktreeDatabase(input: {
|
||||||
sourceConfigPath: string;
|
sourceConfigPath: string;
|
||||||
sourceConfig: PaperclipConfig;
|
sourceConfig: PaperclipConfig;
|
||||||
@@ -1278,7 +929,6 @@ async function seedWorktreeDatabase(input: {
|
|||||||
targetPaths: WorktreeLocalPaths;
|
targetPaths: WorktreeLocalPaths;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
seedMode: WorktreeSeedMode;
|
seedMode: WorktreeSeedMode;
|
||||||
preserveLiveWork?: boolean;
|
|
||||||
}): Promise<SeedWorktreeDatabaseResult> {
|
}): Promise<SeedWorktreeDatabaseResult> {
|
||||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||||
@@ -1309,9 +959,8 @@ async function seedWorktreeDatabase(input: {
|
|||||||
const backup = await runDatabaseBackup({
|
const backup = await runDatabaseBackup({
|
||||||
connectionString: sourceConnectionString,
|
connectionString: sourceConnectionString,
|
||||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||||
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
retentionDays: 7,
|
||||||
filenamePrefix: `${input.instanceId}-seed`,
|
filenamePrefix: `${input.instanceId}-seed`,
|
||||||
backupEngine: "javascript",
|
|
||||||
includeMigrationJournal: true,
|
includeMigrationJournal: true,
|
||||||
excludeTables: seedPlan.excludedTables,
|
excludeTables: seedPlan.excludedTables,
|
||||||
nullifyColumns: seedPlan.nullifyColumns,
|
nullifyColumns: seedPlan.nullifyColumns,
|
||||||
@@ -1330,10 +979,6 @@ async function seedWorktreeDatabase(input: {
|
|||||||
backupFile: backup.backupFile,
|
backupFile: backup.backupFile,
|
||||||
});
|
});
|
||||||
await applyPendingMigrations(targetConnectionString);
|
await applyPendingMigrations(targetConnectionString);
|
||||||
const executionQuarantine = input.preserveLiveWork
|
|
||||||
? { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY }
|
|
||||||
: await quarantineSeededWorktreeExecutionState(targetConnectionString);
|
|
||||||
const pausedScheduledRoutines = await pauseSeededScheduledRoutines(targetConnectionString);
|
|
||||||
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||||
targetConnectionString,
|
targetConnectionString,
|
||||||
currentCwd: input.targetPaths.cwd,
|
currentCwd: input.targetPaths.cwd,
|
||||||
@@ -1341,8 +986,6 @@ async function seedWorktreeDatabase(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
backupSummary: formatDatabaseBackupResult(backup),
|
backupSummary: formatDatabaseBackupResult(backup),
|
||||||
pausedScheduledRoutines,
|
|
||||||
executionQuarantine,
|
|
||||||
reboundWorkspaces,
|
reboundWorkspaces,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1421,8 +1064,6 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||||||
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
|
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
|
||||||
|
|
||||||
let seedSummary: string | null = null;
|
let seedSummary: string | null = null;
|
||||||
let seedExecutionQuarantineSummary: SeededWorktreeExecutionQuarantineSummary | null = null;
|
|
||||||
let pausedScheduledRoutineCount: number | null = null;
|
|
||||||
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||||
if (opts.seed !== false) {
|
if (opts.seed !== false) {
|
||||||
if (!sourceConfig) {
|
if (!sourceConfig) {
|
||||||
@@ -1440,11 +1081,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||||||
targetPaths: paths,
|
targetPaths: paths,
|
||||||
instanceId,
|
instanceId,
|
||||||
seedMode,
|
seedMode,
|
||||||
preserveLiveWork: opts.preserveLiveWork,
|
|
||||||
});
|
});
|
||||||
seedSummary = seeded.backupSummary;
|
seedSummary = seeded.backupSummary;
|
||||||
seedExecutionQuarantineSummary = seeded.executionQuarantine;
|
|
||||||
pausedScheduledRoutineCount = seeded.pausedScheduledRoutines;
|
|
||||||
reboundWorkspaceSummary = seeded.reboundWorkspaces;
|
reboundWorkspaceSummary = seeded.reboundWorkspaces;
|
||||||
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1467,16 +1105,6 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
|||||||
if (seedSummary) {
|
if (seedSummary) {
|
||||||
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
||||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
||||||
if (opts.preserveLiveWork) {
|
|
||||||
p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments.");
|
|
||||||
} else if (seedExecutionQuarantineSummary) {
|
|
||||||
p.log.message(
|
|
||||||
pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seedExecutionQuarantineSummary)}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (pausedScheduledRoutineCount != null) {
|
|
||||||
p.log.message(pc.dim(`Paused scheduled routines: ${pausedScheduledRoutineCount}`));
|
|
||||||
}
|
|
||||||
for (const rebound of reboundWorkspaceSummary) {
|
for (const rebound of reboundWorkspaceSummary) {
|
||||||
p.log.message(
|
p.log.message(
|
||||||
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||||
@@ -1544,7 +1172,18 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
|||||||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
installDependenciesBestEffort(targetPath);
|
const installSpinner = p.spinner();
|
||||||
|
installSpinner.start("Installing dependencies...");
|
||||||
|
try {
|
||||||
|
execFileSync("pnpm", ["install"], {
|
||||||
|
cwd: targetPath,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
installSpinner.stop("Installed dependencies.");
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
||||||
|
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||||
|
}
|
||||||
|
|
||||||
const originalCwd = process.cwd();
|
const originalCwd = process.cwd();
|
||||||
try {
|
try {
|
||||||
@@ -1561,21 +1200,6 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function installDependenciesBestEffort(targetPath: string): void {
|
|
||||||
const installSpinner = p.spinner();
|
|
||||||
installSpinner.start("Installing dependencies...");
|
|
||||||
try {
|
|
||||||
execFileSync("pnpm", ["install"], {
|
|
||||||
cwd: targetPath,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
installSpinner.stop("Installed dependencies.");
|
|
||||||
} catch (error) {
|
|
||||||
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
|
||||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorktreeCleanupOptions = {
|
type WorktreeCleanupOptions = {
|
||||||
instance?: string;
|
instance?: string;
|
||||||
home?: string;
|
home?: string;
|
||||||
@@ -1609,14 +1233,6 @@ type ResolvedWorktreeReseedSource = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedWorktreeRepairTarget = {
|
|
||||||
rootPath: string;
|
|
||||||
configPath: string;
|
|
||||||
label: string;
|
|
||||||
branchName: string | null;
|
|
||||||
created: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
||||||
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -3058,7 +2674,10 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||||
|
printPaperclipCliBanner();
|
||||||
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||||
|
|
||||||
const seedMode = opts.seedMode ?? "full";
|
const seedMode = opts.seedMode ?? "full";
|
||||||
if (!isWorktreeSeedMode(seedMode)) {
|
if (!isWorktreeSeedMode(seedMode)) {
|
||||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||||
@@ -3121,20 +2740,11 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
|||||||
targetPaths,
|
targetPaths,
|
||||||
instanceId: targetPaths.instanceId,
|
instanceId: targetPaths.instanceId,
|
||||||
seedMode,
|
seedMode,
|
||||||
preserveLiveWork: opts.preserveLiveWork,
|
|
||||||
});
|
});
|
||||||
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
|
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
|
||||||
p.log.message(pc.dim(`Source: ${source.configPath}`));
|
p.log.message(pc.dim(`Source: ${source.configPath}`));
|
||||||
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
|
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
|
||||||
p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`));
|
p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`));
|
||||||
if (opts.preserveLiveWork) {
|
|
||||||
p.log.warning("Preserved copied live work; this worktree instance may auto-run source-instance assignments.");
|
|
||||||
} else {
|
|
||||||
p.log.message(
|
|
||||||
pc.dim(`Seed execution quarantine: ${formatSeededWorktreeExecutionQuarantineSummary(seeded.executionQuarantine)}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
p.log.message(pc.dim(`Paused scheduled routines: ${seeded.pausedScheduledRoutines}`));
|
|
||||||
for (const rebound of seeded.reboundWorkspaces) {
|
for (const rebound of seeded.reboundWorkspaces) {
|
||||||
p.log.message(
|
p.log.message(
|
||||||
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||||
@@ -3147,98 +2757,6 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
|
||||||
printPaperclipCliBanner();
|
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
|
||||||
await runWorktreeReseed(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promise<void> {
|
|
||||||
printPaperclipCliBanner();
|
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree repair ")));
|
|
||||||
|
|
||||||
const seedMode = opts.seedMode ?? "minimal";
|
|
||||||
if (!isWorktreeSeedMode(seedMode)) {
|
|
||||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = await ensureRepairTargetWorktree({
|
|
||||||
selector: nonEmpty(opts.branch) ?? undefined,
|
|
||||||
seedMode,
|
|
||||||
opts,
|
|
||||||
});
|
|
||||||
if (!target) {
|
|
||||||
p.log.warn("Current checkout is the primary repo worktree. Pass --branch to create or repair a linked worktree.");
|
|
||||||
p.outro(pc.yellow("No worktree repaired."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = resolveWorktreeRepairSource(opts);
|
|
||||||
if (!existsSync(source.configPath)) {
|
|
||||||
throw new Error(`Source config not found at ${source.configPath}.`);
|
|
||||||
}
|
|
||||||
if (path.resolve(source.configPath) === path.resolve(target.configPath)) {
|
|
||||||
throw new Error("Source and target Paperclip configs are the same. Use --from-config/--from-instance to point repair at a different source.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetConfig = existsSync(target.configPath) ? readConfig(target.configPath) : null;
|
|
||||||
const targetEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(target.configPath));
|
|
||||||
const targetHasWorktreeEnv = Boolean(
|
|
||||||
nonEmpty(targetEnvEntries.PAPERCLIP_HOME) && nonEmpty(targetEnvEntries.PAPERCLIP_INSTANCE_ID),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetConfig && targetHasWorktreeEnv && opts.noSeed) {
|
|
||||||
p.log.message(pc.dim(`Target ${target.label} already has worktree-local config/env. Skipping reseed because --no-seed was passed.`));
|
|
||||||
p.outro(pc.green(`Worktree metadata already looks healthy for ${target.label}.`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetConfig && targetHasWorktreeEnv) {
|
|
||||||
await runWorktreeReseed({
|
|
||||||
fromConfig: source.configPath,
|
|
||||||
to: target.rootPath,
|
|
||||||
seedMode,
|
|
||||||
preserveLiveWork: opts.preserveLiveWork,
|
|
||||||
yes: true,
|
|
||||||
allowLiveTarget: opts.allowLiveTarget,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repairInstanceId = sanitizeWorktreeInstanceId(path.basename(target.rootPath));
|
|
||||||
const repairPaths = resolveWorktreeLocalPaths({
|
|
||||||
cwd: target.rootPath,
|
|
||||||
homeDir: resolveWorktreeHome(opts.home),
|
|
||||||
instanceId: repairInstanceId,
|
|
||||||
});
|
|
||||||
const runningTargetPid = readRunningPostmasterPid(path.resolve(repairPaths.embeddedPostgresDataDir, "postmaster.pid"));
|
|
||||||
if (runningTargetPid && !opts.allowLiveTarget) {
|
|
||||||
throw new Error(
|
|
||||||
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${target.rootPath} before repairing, or re-run with --allow-live-target if you want to override this guard.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (runningTargetPid && opts.allowLiveTarget) {
|
|
||||||
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
try {
|
|
||||||
process.chdir(target.rootPath);
|
|
||||||
await runWorktreeInit({
|
|
||||||
home: opts.home,
|
|
||||||
fromConfig: source.configPath,
|
|
||||||
fromDataDir: opts.fromDataDir,
|
|
||||||
fromInstance: opts.fromInstance,
|
|
||||||
seed: opts.noSeed ? false : true,
|
|
||||||
seedMode,
|
|
||||||
preserveLiveWork: opts.preserveLiveWork,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerWorktreeCommands(program: Command): void {
|
export function registerWorktreeCommands(program: Command): void {
|
||||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||||
|
|
||||||
@@ -3255,7 +2773,6 @@ export function registerWorktreeCommands(program: Command): void {
|
|||||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||||
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
|
|
||||||
.option("--no-seed", "Skip database seeding from the source instance")
|
.option("--no-seed", "Skip database seeding from the source instance")
|
||||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||||
.action(worktreeMakeCommand);
|
.action(worktreeMakeCommand);
|
||||||
@@ -3272,7 +2789,6 @@ export function registerWorktreeCommands(program: Command): void {
|
|||||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||||
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
|
|
||||||
.option("--no-seed", "Skip database seeding from the source instance")
|
.option("--no-seed", "Skip database seeding from the source instance")
|
||||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||||
.action(worktreeInitCommand);
|
.action(worktreeInitCommand);
|
||||||
@@ -3312,25 +2828,10 @@ export function registerWorktreeCommands(program: Command): void {
|
|||||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||||
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
||||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
|
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
|
||||||
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
|
|
||||||
.option("--yes", "Skip the destructive confirmation prompt", false)
|
.option("--yes", "Skip the destructive confirmation prompt", false)
|
||||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||||
.action(worktreeReseedCommand);
|
.action(worktreeReseedCommand);
|
||||||
|
|
||||||
worktree
|
|
||||||
.command("repair")
|
|
||||||
.description("Create or repair a linked worktree-local Paperclip instance without touching the primary checkout")
|
|
||||||
.option("--branch <name>", "Existing branch/worktree selector to repair, or a branch name to create under .paperclip/worktrees")
|
|
||||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
|
||||||
.option("--from-config <path>", "Source config.json to seed from")
|
|
||||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
|
||||||
.option("--from-instance <id>", "Source instance id when deriving the source config (default: default)")
|
|
||||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
|
||||||
.option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false)
|
|
||||||
.option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false)
|
|
||||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
|
||||||
.action(worktreeRepairCommand);
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("worktree:cleanup")
|
.command("worktree:cleanup")
|
||||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
|
||||||
expandHomePrefix,
|
|
||||||
resolveDefaultBackupDir as resolveSharedDefaultBackupDir,
|
|
||||||
resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir,
|
|
||||||
resolveDefaultLogsDir as resolveSharedDefaultLogsDir,
|
|
||||||
resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath,
|
|
||||||
resolveDefaultStorageDir as resolveSharedDefaultStorageDir,
|
|
||||||
resolveHomeAwarePath,
|
|
||||||
resolvePaperclipConfigPathForInstance,
|
|
||||||
resolvePaperclipHomeDir,
|
|
||||||
resolvePaperclipInstanceId,
|
|
||||||
resolvePaperclipInstanceRoot as resolveSharedPaperclipInstanceRoot,
|
|
||||||
} from "@paperclipai/shared/home-paths";
|
|
||||||
|
|
||||||
export {
|
const DEFAULT_INSTANCE_ID = "default";
|
||||||
expandHomePrefix,
|
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||||
resolveHomeAwarePath,
|
|
||||||
resolvePaperclipHomeDir,
|
export function resolvePaperclipHomeDir(): string {
|
||||||
resolvePaperclipInstanceId,
|
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||||
};
|
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||||
|
return path.resolve(os.homedir(), ".paperclip");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePaperclipInstanceId(override?: string): string {
|
||||||
|
const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||||
|
if (!INSTANCE_ID_RE.test(raw)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolvePaperclipInstanceRoot(instanceId?: string): string {
|
export function resolvePaperclipInstanceRoot(instanceId?: string): string {
|
||||||
return resolveSharedPaperclipInstanceRoot({ instanceId });
|
const id = resolvePaperclipInstanceId(instanceId);
|
||||||
|
return path.resolve(resolvePaperclipHomeDir(), "instances", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultConfigPath(instanceId?: string): string {
|
export function resolveDefaultConfigPath(instanceId?: string): string {
|
||||||
return resolvePaperclipConfigPathForInstance({ instanceId });
|
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultContextPath(): string {
|
export function resolveDefaultContextPath(): string {
|
||||||
@@ -37,23 +38,29 @@ export function resolveDefaultCliAuthPath(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
||||||
return resolveSharedDefaultEmbeddedPostgresDir({ instanceId });
|
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultLogsDir(instanceId?: string): string {
|
export function resolveDefaultLogsDir(instanceId?: string): string {
|
||||||
return resolveSharedDefaultLogsDir({ instanceId });
|
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
|
export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
|
||||||
return resolveSharedDefaultSecretsKeyFilePath({ instanceId });
|
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultStorageDir(instanceId?: string): string {
|
export function resolveDefaultStorageDir(instanceId?: string): string {
|
||||||
return resolveSharedDefaultStorageDir({ instanceId });
|
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultBackupDir(instanceId?: string): string {
|
export function resolveDefaultBackupDir(instanceId?: string): string {
|
||||||
return resolveSharedDefaultBackupDir({ instanceId });
|
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandHomePrefix(value: string): string {
|
||||||
|
if (value === "~") return os.homedir();
|
||||||
|
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeLocalInstancePaths(instanceId?: string) {
|
export function describeLocalInstancePaths(instanceId?: string) {
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import {
|
|
||||||
ALL_INTERFACES_BIND_HOST,
|
|
||||||
LOOPBACK_BIND_HOST,
|
|
||||||
inferBindModeFromHost,
|
|
||||||
isAllInterfacesHost,
|
|
||||||
isLoopbackHost,
|
|
||||||
type BindMode,
|
|
||||||
type DeploymentExposure,
|
|
||||||
type DeploymentMode,
|
|
||||||
} from "@paperclipai/shared";
|
|
||||||
import type { AuthConfig, ServerConfig } from "./schema.js";
|
|
||||||
|
|
||||||
const TAILSCALE_DETECT_TIMEOUT_MS = 3000;
|
|
||||||
|
|
||||||
type BaseServerInput = {
|
|
||||||
port: number;
|
|
||||||
allowedHostnames: string[];
|
|
||||||
serveUi: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function inferConfiguredBind(server?: Partial<ServerConfig>): BindMode {
|
|
||||||
if (server?.bind) return server.bind;
|
|
||||||
return inferBindModeFromHost(server?.customBindHost ?? server?.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectTailnetBindHost(): string | undefined {
|
|
||||||
const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
|
|
||||||
if (explicit) return explicit;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stdout = execFileSync("tailscale", ["ip", "-4"], {
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
|
||||||
timeout: TAILSCALE_DETECT_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
return stdout
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find(Boolean);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPresetServerConfig(
|
|
||||||
bind: Exclude<BindMode, "custom">,
|
|
||||||
input: BaseServerInput,
|
|
||||||
): { server: ServerConfig; auth: AuthConfig } {
|
|
||||||
const host =
|
|
||||||
bind === "loopback"
|
|
||||||
? LOOPBACK_BIND_HOST
|
|
||||||
: bind === "tailnet"
|
|
||||||
? (detectTailnetBindHost() ?? LOOPBACK_BIND_HOST)
|
|
||||||
: ALL_INTERFACES_BIND_HOST;
|
|
||||||
|
|
||||||
return {
|
|
||||||
server: {
|
|
||||||
deploymentMode: bind === "loopback" ? "local_trusted" : "authenticated",
|
|
||||||
exposure: "private",
|
|
||||||
bind,
|
|
||||||
customBindHost: undefined,
|
|
||||||
host,
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
baseUrlMode: "auto",
|
|
||||||
disableSignUp: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCustomServerConfig(input: BaseServerInput & {
|
|
||||||
deploymentMode: DeploymentMode;
|
|
||||||
exposure: DeploymentExposure;
|
|
||||||
host: string;
|
|
||||||
publicBaseUrl?: string;
|
|
||||||
}): { server: ServerConfig; auth: AuthConfig } {
|
|
||||||
const normalizedHost = input.host.trim();
|
|
||||||
const bind = isLoopbackHost(normalizedHost)
|
|
||||||
? "loopback"
|
|
||||||
: isAllInterfacesHost(normalizedHost)
|
|
||||||
? "lan"
|
|
||||||
: "custom";
|
|
||||||
|
|
||||||
return {
|
|
||||||
server: {
|
|
||||||
deploymentMode: input.deploymentMode,
|
|
||||||
exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure,
|
|
||||||
bind,
|
|
||||||
customBindHost: bind === "custom" ? normalizedHost : undefined,
|
|
||||||
host: normalizedHost,
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
},
|
|
||||||
auth:
|
|
||||||
input.deploymentMode === "authenticated" && input.exposure === "public"
|
|
||||||
? {
|
|
||||||
baseUrlMode: "explicit",
|
|
||||||
disableSignUp: false,
|
|
||||||
publicBaseUrl: input.publicBaseUrl,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
baseUrlMode: "auto",
|
|
||||||
disableSignUp: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveQuickstartServerConfig(input: {
|
|
||||||
bind?: BindMode | null;
|
|
||||||
deploymentMode?: DeploymentMode | null;
|
|
||||||
exposure?: DeploymentExposure | null;
|
|
||||||
host?: string | null;
|
|
||||||
port: number;
|
|
||||||
allowedHostnames: string[];
|
|
||||||
serveUi: boolean;
|
|
||||||
publicBaseUrl?: string;
|
|
||||||
}): { server: ServerConfig; auth: AuthConfig } {
|
|
||||||
const trimmedHost = input.host?.trim();
|
|
||||||
const explicitBind = input.bind ?? null;
|
|
||||||
|
|
||||||
if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") {
|
|
||||||
return buildPresetServerConfig(explicitBind, {
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (explicitBind === "custom") {
|
|
||||||
return buildCustomServerConfig({
|
|
||||||
deploymentMode: input.deploymentMode ?? "authenticated",
|
|
||||||
exposure: input.exposure ?? "private",
|
|
||||||
host: trimmedHost || LOOPBACK_BIND_HOST,
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
publicBaseUrl: input.publicBaseUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedHost) {
|
|
||||||
return buildCustomServerConfig({
|
|
||||||
deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"),
|
|
||||||
exposure: input.exposure ?? "private",
|
|
||||||
host: trimmedHost,
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
publicBaseUrl: input.publicBaseUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.deploymentMode === "authenticated") {
|
|
||||||
if (input.exposure === "public") {
|
|
||||||
return buildCustomServerConfig({
|
|
||||||
deploymentMode: "authenticated",
|
|
||||||
exposure: "public",
|
|
||||||
host: ALL_INTERFACES_BIND_HOST,
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
publicBaseUrl: input.publicBaseUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildPresetServerConfig("lan", {
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildPresetServerConfig("loopback", {
|
|
||||||
port: input.port,
|
|
||||||
allowedHostnames: input.allowedHostnames,
|
|
||||||
serveUi: input.serveUi,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import { heartbeatRun } from "./commands/heartbeat-run.js";
|
|||||||
import { runCommand } from "./commands/run.js";
|
import { runCommand } from "./commands/run.js";
|
||||||
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
||||||
import { dbBackupCommand } from "./commands/db-backup.js";
|
import { dbBackupCommand } from "./commands/db-backup.js";
|
||||||
import { registerEnvLabCommands } from "./commands/env-lab.js";
|
|
||||||
import { registerContextCommands } from "./commands/client/context.js";
|
import { registerContextCommands } from "./commands/client/context.js";
|
||||||
import { registerCompanyCommands } from "./commands/client/company.js";
|
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||||
@@ -18,7 +17,6 @@ import { registerActivityCommands } from "./commands/client/activity.js";
|
|||||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||||
import { registerRoutineCommands } from "./commands/routines.js";
|
import { registerRoutineCommands } from "./commands/routines.js";
|
||||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||||
import { registerSecretCommands } from "./commands/client/secrets.js";
|
|
||||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||||
@@ -52,8 +50,7 @@ program
|
|||||||
.description("Interactive first-run setup wizard")
|
.description("Interactive first-run setup wizard")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("--bind <mode>", "Quickstart reachability preset (loopback, lan, tailnet)")
|
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
|
||||||
.option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false)
|
|
||||||
.option("--run", "Start Paperclip immediately after saving config", false)
|
.option("--run", "Start Paperclip immediately after saving config", false)
|
||||||
.action(onboard);
|
.action(onboard);
|
||||||
|
|
||||||
@@ -111,7 +108,6 @@ program
|
|||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("-i, --instance <id>", "Local instance id (default: default)")
|
.option("-i, --instance <id>", "Local instance id (default: default)")
|
||||||
.option("--bind <mode>", "On first run, use onboarding reachability preset (loopback, lan, tailnet)")
|
|
||||||
.option("--repair", "Attempt automatic repairs during doctor", true)
|
.option("--repair", "Attempt automatic repairs during doctor", true)
|
||||||
.option("--no-repair", "Disable automatic repairs during doctor")
|
.option("--no-repair", "Disable automatic repairs during doctor")
|
||||||
.action(runCommand);
|
.action(runCommand);
|
||||||
@@ -148,9 +144,7 @@ registerActivityCommands(program);
|
|||||||
registerDashboardCommands(program);
|
registerDashboardCommands(program);
|
||||||
registerRoutineCommands(program);
|
registerRoutineCommands(program);
|
||||||
registerFeedbackCommands(program);
|
registerFeedbackCommands(program);
|
||||||
registerSecretCommands(program);
|
|
||||||
registerWorktreeCommands(program);
|
registerWorktreeCommands(program);
|
||||||
registerEnvLabCommands(program);
|
|
||||||
registerPluginCommands(program);
|
registerPluginCommands(program);
|
||||||
|
|
||||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
|||||||
{
|
{
|
||||||
value: "aws_secrets_manager" as const,
|
value: "aws_secrets_manager" as const,
|
||||||
label: "AWS Secrets Manager",
|
label: "AWS Secrets Manager",
|
||||||
hint: "requires runtime AWS credentials and provider env config",
|
hint: "requires external adapter integration",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "gcp_secret_manager" as const,
|
value: "gcp_secret_manager" as const,
|
||||||
@@ -84,9 +84,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
|||||||
|
|
||||||
if (provider !== "local_encrypted") {
|
if (provider !== "local_encrypted") {
|
||||||
p.note(
|
p.note(
|
||||||
provider === "aws_secrets_manager"
|
`${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
||||||
? "AWS credentials must come from the Paperclip server runtime (IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, or short-lived shell env), not from Paperclip company secrets."
|
|
||||||
: `${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
|
||||||
"Heads up",
|
"Heads up",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
|
|
||||||
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
||||||
import { parseHostnameCsv } from "../config/hostnames.js";
|
import { parseHostnameCsv } from "../config/hostnames.js";
|
||||||
import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js";
|
|
||||||
|
|
||||||
const TAILNET_BIND_WARNING =
|
|
||||||
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
|
|
||||||
|
|
||||||
function cancelled(): never {
|
|
||||||
p.cancel("Setup cancelled.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function promptServer(opts?: {
|
export async function promptServer(opts?: {
|
||||||
currentServer?: Partial<ServerConfig>;
|
currentServer?: Partial<ServerConfig>;
|
||||||
@@ -18,37 +8,69 @@ export async function promptServer(opts?: {
|
|||||||
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
|
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
|
||||||
const currentServer = opts?.currentServer;
|
const currentServer = opts?.currentServer;
|
||||||
const currentAuth = opts?.currentAuth;
|
const currentAuth = opts?.currentAuth;
|
||||||
const currentBind = inferConfiguredBind(currentServer);
|
|
||||||
|
|
||||||
const bindSelection = await p.select({
|
const deploymentModeSelection = await p.select({
|
||||||
message: "Reachability",
|
message: "Deployment mode",
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: "loopback" as const,
|
value: "local_trusted",
|
||||||
label: "Trusted local",
|
label: "Local trusted",
|
||||||
hint: "Recommended for first run: localhost only, no login friction",
|
hint: "Easiest for local setup (no login, localhost-only)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "lan" as const,
|
value: "authenticated",
|
||||||
label: "Private network",
|
label: "Authenticated",
|
||||||
hint: "Broad private bind for LAN, VPN, or legacy --tailscale-auth style access",
|
hint: "Login required; use for private network or public hosting",
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "tailnet" as const,
|
|
||||||
label: "Tailnet",
|
|
||||||
hint: "Private authenticated access using the machine's detected Tailscale address",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "custom" as const,
|
|
||||||
label: "Custom",
|
|
||||||
hint: "Choose exact auth mode, exposure, and host manually",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
initialValue: currentBind,
|
initialValue: currentServer?.deploymentMode ?? "local_trusted",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(bindSelection)) cancelled();
|
if (p.isCancel(deploymentModeSelection)) {
|
||||||
const bind = bindSelection as BindMode;
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
|
||||||
|
|
||||||
|
let exposure: ServerConfig["exposure"] = "private";
|
||||||
|
if (deploymentMode === "authenticated") {
|
||||||
|
const exposureSelection = await p.select({
|
||||||
|
message: "Exposure profile",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "private",
|
||||||
|
label: "Private network",
|
||||||
|
hint: "Private access (for example Tailscale), lower setup friction",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "public",
|
||||||
|
label: "Public internet",
|
||||||
|
hint: "Internet-facing deployment with stricter requirements",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: currentServer?.exposure ?? "private",
|
||||||
|
});
|
||||||
|
if (p.isCancel(exposureSelection)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
exposure = exposureSelection as ServerConfig["exposure"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0";
|
||||||
|
const hostStr = await p.text({
|
||||||
|
message: "Bind host",
|
||||||
|
defaultValue: currentServer?.host ?? hostDefault,
|
||||||
|
placeholder: hostDefault,
|
||||||
|
validate: (val) => {
|
||||||
|
if (!val.trim()) return "Host is required";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(hostStr)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const portStr = await p.text({
|
const portStr = await p.text({
|
||||||
message: "Server port",
|
message: "Server port",
|
||||||
@@ -62,113 +84,15 @@ export async function promptServer(opts?: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(portStr)) cancelled();
|
if (p.isCancel(portStr)) {
|
||||||
const port = Number(portStr) || 3100;
|
p.cancel("Setup cancelled.");
|
||||||
const serveUi = currentServer?.serveUi ?? true;
|
process.exit(0);
|
||||||
|
|
||||||
if (bind === "loopback") {
|
|
||||||
return buildPresetServerConfig("loopback", {
|
|
||||||
port,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bind === "lan" || bind === "tailnet") {
|
|
||||||
const allowedHostnamesInput = await p.text({
|
|
||||||
message: "Allowed private hostnames (comma-separated, optional)",
|
|
||||||
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
|
||||||
placeholder:
|
|
||||||
bind === "tailnet"
|
|
||||||
? "your-machine.tailnet.ts.net"
|
|
||||||
: "dotta-macbook-pro, host.docker.internal",
|
|
||||||
validate: (val) => {
|
|
||||||
try {
|
|
||||||
parseHostnameCsv(val);
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
return err instanceof Error ? err.message : "Invalid hostname list";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
|
||||||
|
|
||||||
const preset = buildPresetServerConfig(bind, {
|
|
||||||
port,
|
|
||||||
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
|
|
||||||
serveUi,
|
|
||||||
});
|
|
||||||
if (bind === "tailnet" && isLoopbackHost(preset.server.host)) {
|
|
||||||
p.log.warn(TAILNET_BIND_WARNING);
|
|
||||||
}
|
|
||||||
return preset;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deploymentModeSelection = await p.select({
|
|
||||||
message: "Auth mode",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "local_trusted",
|
|
||||||
label: "Local trusted",
|
|
||||||
hint: "No login required; only safe with loopback-only or similarly trusted access",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "authenticated",
|
|
||||||
label: "Authenticated",
|
|
||||||
hint: "Login required; supports both private-network and public deployments",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
initialValue: currentServer?.deploymentMode ?? "authenticated",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(deploymentModeSelection)) cancelled();
|
|
||||||
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
|
|
||||||
|
|
||||||
let exposure: ServerConfig["exposure"] = "private";
|
|
||||||
if (deploymentMode === "authenticated") {
|
|
||||||
const exposureSelection = await p.select({
|
|
||||||
message: "Exposure profile",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: "private",
|
|
||||||
label: "Private network",
|
|
||||||
hint: "Private access only, with automatic URL handling",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "public",
|
|
||||||
label: "Public internet",
|
|
||||||
hint: "Internet-facing deployment with explicit public URL requirements",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
initialValue: currentServer?.exposure ?? "private",
|
|
||||||
});
|
|
||||||
if (p.isCancel(exposureSelection)) cancelled();
|
|
||||||
exposure = exposureSelection as ServerConfig["exposure"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultHost =
|
|
||||||
currentServer?.customBindHost ??
|
|
||||||
currentServer?.host ??
|
|
||||||
(deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0");
|
|
||||||
const host = await p.text({
|
|
||||||
message: "Bind host",
|
|
||||||
defaultValue: defaultHost,
|
|
||||||
placeholder: defaultHost,
|
|
||||||
validate: (val) => {
|
|
||||||
if (!val.trim()) return "Host is required";
|
|
||||||
if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) {
|
|
||||||
return "Local trusted mode requires a loopback host such as 127.0.0.1";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(host)) cancelled();
|
|
||||||
|
|
||||||
let allowedHostnames: string[] = [];
|
let allowedHostnames: string[] = [];
|
||||||
if (deploymentMode === "authenticated" && exposure === "private") {
|
if (deploymentMode === "authenticated" && exposure === "private") {
|
||||||
const allowedHostnamesInput = await p.text({
|
const allowedHostnamesInput = await p.text({
|
||||||
message: "Allowed private hostnames (comma-separated, optional)",
|
message: "Allowed hostnames (comma-separated, optional)",
|
||||||
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
||||||
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
|
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
|
||||||
validate: (val) => {
|
validate: (val) => {
|
||||||
@@ -181,11 +105,15 @@ export async function promptServer(opts?: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
if (p.isCancel(allowedHostnamesInput)) {
|
||||||
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
|
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
let publicBaseUrl: string | undefined;
|
const port = Number(portStr) || 3100;
|
||||||
|
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
|
||||||
if (deploymentMode === "authenticated" && exposure === "public") {
|
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||||
const urlInput = await p.text({
|
const urlInput = await p.text({
|
||||||
message: "Public base URL",
|
message: "Public base URL",
|
||||||
@@ -205,17 +133,32 @@ export async function promptServer(opts?: {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (p.isCancel(urlInput)) cancelled();
|
if (p.isCancel(urlInput)) {
|
||||||
publicBaseUrl = urlInput.trim().replace(/\/+$/, "");
|
p.cancel("Setup cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
auth = {
|
||||||
|
baseUrlMode: "explicit",
|
||||||
|
disableSignUp: false,
|
||||||
|
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
||||||
|
};
|
||||||
|
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
||||||
|
auth = {
|
||||||
|
baseUrlMode: "explicit",
|
||||||
|
disableSignUp: false,
|
||||||
|
publicBaseUrl: currentAuth.publicBaseUrl,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCustomServerConfig({
|
return {
|
||||||
deploymentMode,
|
server: {
|
||||||
exposure,
|
deploymentMode,
|
||||||
host: host.trim(),
|
exposure,
|
||||||
port,
|
host: hostStr.trim(),
|
||||||
allowedHostnames,
|
port,
|
||||||
serveUi,
|
allowedHostnames,
|
||||||
publicBaseUrl,
|
serveUi: currentServer?.serveUi ?? true,
|
||||||
});
|
},
|
||||||
|
auth,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": ".."
|
"rootDir": ".."
|
||||||
},
|
},
|
||||||
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
|
"include": ["src", "../packages/shared/src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Paperclip CLI now supports both:
|
Paperclip CLI now supports both:
|
||||||
|
|
||||||
- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`, `env-lab`)
|
- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`)
|
||||||
- control-plane client operations (issues, approvals, agents, activity, dashboard)
|
- control-plane client operations (issues, approvals, agents, activity, dashboard)
|
||||||
|
|
||||||
## Base Usage
|
## Base Usage
|
||||||
@@ -32,12 +32,10 @@ Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`.
|
|||||||
Current CLI behavior:
|
Current CLI behavior:
|
||||||
|
|
||||||
- `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config
|
- `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config
|
||||||
- server onboarding/configure ask for reachability intent and write `server.bind`
|
|
||||||
- `paperclipai run --bind <loopback|lan|tailnet>` passes a quickstart bind preset into first-run onboarding when config is missing
|
|
||||||
- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE`
|
- runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE`
|
||||||
- `paperclipai run` and `paperclipai doctor` still do not expose a direct low-level `--mode` flag
|
- `paperclipai run` and `paperclipai doctor` do not yet expose a direct `--mode` flag
|
||||||
|
|
||||||
Canonical behavior is documented in `doc/DEPLOYMENT-MODES.md`.
|
Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5.
|
||||||
|
|
||||||
Allow an authenticated/private hostname (for example custom Tailscale DNS):
|
Allow an authenticated/private hostname (for example custom Tailscale DNS):
|
||||||
|
|
||||||
@@ -45,15 +43,6 @@ Allow an authenticated/private hostname (for example custom Tailscale DNS):
|
|||||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
||||||
```
|
```
|
||||||
|
|
||||||
Bring up the default local SSH fixture for environment testing:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm paperclipai env-lab up
|
|
||||||
pnpm paperclipai env-lab doctor
|
|
||||||
pnpm paperclipai env-lab status --json
|
|
||||||
pnpm paperclipai env-lab down
|
|
||||||
```
|
|
||||||
|
|
||||||
All client commands support:
|
All client commands support:
|
||||||
|
|
||||||
- `--data-dir <path>`
|
- `--data-dir <path>`
|
||||||
@@ -143,32 +132,6 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
|||||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Secrets Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm paperclipai secrets list --company-id <company-id>
|
|
||||||
pnpm paperclipai secrets declarations --company-id <company-id> [--include agents,projects] [--kind secret]
|
|
||||||
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
|
||||||
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
|
||||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
|
||||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> [--apply]
|
|
||||||
```
|
|
||||||
|
|
||||||
Secret listing and declarations never print secret values. `create` accepts
|
|
||||||
`--value-env` so shell history does not capture the value. `link` records
|
|
||||||
provider-owned references without copying the secret value into Paperclip.
|
|
||||||
For AWS-backed secrets, `secrets doctor` reports missing non-secret provider
|
|
||||||
env and the expected AWS SDK runtime credential source; do not store AWS
|
|
||||||
bootstrap credentials in Paperclip secrets.
|
|
||||||
|
|
||||||
Per-company provider vaults (multiple vault instances per provider, default
|
|
||||||
vault selection, coming-soon GCP/Vault) are configured from the board UI under
|
|
||||||
`Company Settings → Secrets → Provider vaults` or through
|
|
||||||
`/api/companies/{companyId}/secret-provider-configs`. There is no CLI surface
|
|
||||||
for vault management today. See the
|
|
||||||
[secrets deploy guide](../docs/deploy/secrets.md#provider-vaults) and
|
|
||||||
[API reference](../docs/api/secrets.md#provider-vaults) for the contract.
|
|
||||||
|
|
||||||
## Approval Commands
|
## Approval Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -204,28 +167,7 @@ pnpm paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhos
|
|||||||
|
|
||||||
## Local Storage Defaults
|
## Local Storage Defaults
|
||||||
|
|
||||||
Local Paperclip data lives under the selected instance root. `PAPERCLIP_HOME` chooses the home directory and `PAPERCLIP_INSTANCE_ID` chooses the instance.
|
Default local instance root is `~/.paperclip/instances/default`:
|
||||||
|
|
||||||
```text
|
|
||||||
~/.paperclip/ # PAPERCLIP_HOME
|
|
||||||
└── instances/
|
|
||||||
└── default/ # instance root (PAPERCLIP_INSTANCE_ID)
|
|
||||||
├── config.json # runtime config
|
|
||||||
├── .env # instance env file
|
|
||||||
├── db/ # embedded PostgreSQL data
|
|
||||||
├── data/
|
|
||||||
│ ├── storage/ # local_disk uploads
|
|
||||||
│ └── backups/ # automatic DB backups
|
|
||||||
├── logs/
|
|
||||||
├── secrets/
|
|
||||||
│ └── master.key # local_encrypted master key
|
|
||||||
├── workspaces/ # default agent workspaces
|
|
||||||
├── projects/ # project execution workspaces
|
|
||||||
├── companies/ # per-company adapter homes (e.g. codex-home)
|
|
||||||
└── codex-home/ # per-instance codex home (when not company-scoped)
|
|
||||||
```
|
|
||||||
|
|
||||||
Default paths for the canonical install:
|
|
||||||
|
|
||||||
- config: `~/.paperclip/instances/default/config.json`
|
- config: `~/.paperclip/instances/default/config.json`
|
||||||
- embedded db: `~/.paperclip/instances/default/db`
|
- embedded db: `~/.paperclip/instances/default/db`
|
||||||
|
|||||||
@@ -27,18 +27,6 @@ pnpm db:migrate
|
|||||||
|
|
||||||
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
|
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
|
||||||
|
|
||||||
Issue reference mentions follow the normal migration path: the schema migration creates the tracking table, but it does not backfill historical issue titles, descriptions, comments, or documents automatically.
|
|
||||||
|
|
||||||
To backfill existing content manually after migrating, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm issue-references:backfill
|
|
||||||
# optional: limit to one company
|
|
||||||
pnpm issue-references:backfill -- --company <company-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
Future issue, comment, and document writes sync references automatically without running the backfill command.
|
|
||||||
|
|
||||||
This mode is ideal for local development and one-command installs.
|
This mode is ideal for local development and one-command installs.
|
||||||
|
|
||||||
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
|
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
|
||||||
@@ -59,11 +47,11 @@ cp .env.example .env
|
|||||||
# DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
|
# DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
|
||||||
```
|
```
|
||||||
|
|
||||||
Run migrations:
|
Run migrations (once the migration generation issue is fixed) or use `drizzle-kit push`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip \
|
DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip \
|
||||||
pnpm db:migrate
|
npx drizzle-kit push
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the server:
|
Start the server:
|
||||||
@@ -100,27 +88,27 @@ postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:
|
|||||||
|
|
||||||
### Configure
|
### Configure
|
||||||
|
|
||||||
For the application runtime, use a direct PostgreSQL connection unless the database client has explicit prepared-statement configuration for your pooling mode:
|
Set `DATABASE_URL` in your `.env`:
|
||||||
|
|
||||||
```sh
|
|
||||||
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
If you later run the app with a pooled runtime URL, set `DATABASE_MIGRATION_URL` to the direct connection URL. Paperclip uses it for startup schema checks/migrations and plugin namespace migrations, while the app continues to use `DATABASE_URL` for runtime queries:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
|
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
|
||||||
DATABASE_MIGRATION_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If your hosted database requires transaction-pooling-only connections, use a direct or session-pooled connection for Paperclip until runtime pooling support is documented in this guide. Do not edit database client source files as part of deployment setup.
|
If using connection pooling (port 6543), the `postgres` client must disable prepared statements. Update `packages/db/src/client.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function createDb(url: string) {
|
||||||
|
const sql = postgres(url, { prepare: false });
|
||||||
|
return drizzlePg(sql, { schema });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Push the schema
|
### Push the schema
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Use the direct connection (port 5432) for schema changes
|
# Use the direct connection (port 5432) for schema changes
|
||||||
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@...5432/postgres \
|
DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@...5432/postgres \
|
||||||
pnpm db:migrate
|
npx drizzle-kit push
|
||||||
```
|
```
|
||||||
|
|
||||||
### Free tier limits
|
### Free tier limits
|
||||||
@@ -143,22 +131,6 @@ The database mode is controlled by `DATABASE_URL`:
|
|||||||
|
|
||||||
Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode.
|
Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode.
|
||||||
|
|
||||||
## Plugin database namespaces
|
|
||||||
|
|
||||||
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.
|
|
||||||
|
|
||||||
## Backups
|
|
||||||
|
|
||||||
Paperclip supports automatic and manual logical database backups. These dumps include
|
|
||||||
non-system database schemas such as `public`, the Drizzle migration journal, and
|
|
||||||
plugin-owned database schemas. See `doc/DEVELOPING.md` for the current
|
|
||||||
`paperclipai db:backup` / `pnpm db:backup` commands and backup retention
|
|
||||||
configuration.
|
|
||||||
|
|
||||||
Database backups do not include non-database instance files such as local-disk
|
|
||||||
uploads, workspace files, or the local encrypted secrets master key. Back those paths
|
|
||||||
up separately when you need full instance disaster recovery.
|
|
||||||
|
|
||||||
## Secret storage
|
## Secret storage
|
||||||
|
|
||||||
Paperclip stores secret metadata and versions in:
|
Paperclip stores secret metadata and versions in:
|
||||||
@@ -171,8 +143,6 @@ For local/default installs, the active provider is `local_encrypted`:
|
|||||||
- Secret material is encrypted at rest with a local master key.
|
- Secret material is encrypted at rest with a local master key.
|
||||||
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
||||||
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||||
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
|
|
||||||
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
|
|
||||||
|
|
||||||
Optional overrides:
|
Optional overrides:
|
||||||
|
|
||||||
@@ -194,10 +164,5 @@ pnpm paperclipai configure --section secrets
|
|||||||
Inline secret migration command:
|
Inline secret migration command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
|
||||||
|
|
||||||
# direct database maintenance fallback
|
|
||||||
pnpm secrets:migrate-inline-env --apply
|
pnpm secrets:migrate-inline-env --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ Paperclip supports two runtime modes:
|
|||||||
|
|
||||||
This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements.
|
This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements.
|
||||||
|
|
||||||
Paperclip now treats **bind** as a separate concern from auth:
|
|
||||||
|
|
||||||
- auth model: `local_trusted` vs `authenticated`, plus `private/public`
|
|
||||||
- reachability model: `server.bind = loopback | lan | tailnet | custom`
|
|
||||||
|
|
||||||
## 2. Canonical Model
|
## 2. Canonical Model
|
||||||
|
|
||||||
| Runtime Mode | Exposure | Human auth | Primary use |
|
| Runtime Mode | Exposure | Human auth | Primary use |
|
||||||
@@ -30,15 +25,6 @@ Paperclip now treats **bind** as a separate concern from auth:
|
|||||||
| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) |
|
| `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) |
|
||||||
| `authenticated` | `public` | Login required | Internet-facing/cloud deployment |
|
| `authenticated` | `public` | Login required | Internet-facing/cloud deployment |
|
||||||
|
|
||||||
## Reachability Model
|
|
||||||
|
|
||||||
| Bind | Meaning | Typical use |
|
|
||||||
|---|---|---|
|
|
||||||
| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments |
|
|
||||||
| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access |
|
|
||||||
| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access |
|
|
||||||
| `custom` | Listen on an explicit host/IP | advanced interface-specific setups |
|
|
||||||
|
|
||||||
## 3. Security Policy
|
## 3. Security Policy
|
||||||
|
|
||||||
## `local_trusted`
|
## `local_trusted`
|
||||||
@@ -52,14 +38,12 @@ Paperclip now treats **bind** as a separate concern from auth:
|
|||||||
- login required
|
- login required
|
||||||
- low-friction URL handling (`auto` base URL mode)
|
- low-friction URL handling (`auto` base URL mode)
|
||||||
- private-host trust policy required
|
- private-host trust policy required
|
||||||
- bind can be `loopback`, `lan`, `tailnet`, or `custom`
|
|
||||||
|
|
||||||
## `authenticated + public`
|
## `authenticated + public`
|
||||||
|
|
||||||
- login required
|
- login required
|
||||||
- explicit public URL required
|
- explicit public URL required
|
||||||
- stricter deployment checks and failures in doctor
|
- stricter deployment checks and failures in doctor
|
||||||
- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced
|
|
||||||
|
|
||||||
## 4. Onboarding UX Contract
|
## 4. Onboarding UX Contract
|
||||||
|
|
||||||
@@ -71,22 +55,14 @@ pnpm paperclipai onboard
|
|||||||
|
|
||||||
Server prompt behavior:
|
Server prompt behavior:
|
||||||
|
|
||||||
1. quickstart `--yes` defaults to `server.bind=loopback` and therefore `local_trusted/private`
|
1. ask mode, default `local_trusted`
|
||||||
2. advanced server setup asks reachability first:
|
2. option copy:
|
||||||
- `Trusted local` → `bind=loopback`, `local_trusted/private`
|
- `local_trusted`: "Easiest for local setup (no login, localhost-only)"
|
||||||
- `Private network` → `bind=lan`, `authenticated/private`
|
- `authenticated`: "Login required; use for private network or public hosting"
|
||||||
- `Tailnet` → `bind=tailnet`, `authenticated/private`
|
3. if `authenticated`, ask exposure:
|
||||||
- `Custom` → manual mode/exposure/host entry
|
- `private`: "Private network access (for example Tailscale), lower setup friction"
|
||||||
3. raw host entry is only required for the `Custom` path
|
- `public`: "Internet-facing deployment, stricter security requirements"
|
||||||
4. explicit public URL is only required for `authenticated + public`
|
4. ask explicit public URL only for `authenticated + public`
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm paperclipai onboard --yes
|
|
||||||
pnpm paperclipai onboard --yes --bind lan
|
|
||||||
pnpm paperclipai run --bind tailnet
|
|
||||||
```
|
|
||||||
|
|
||||||
`configure --section server` follows the same interactive behavior.
|
`configure --section server` follows the same interactive behavior.
|
||||||
|
|
||||||
@@ -142,4 +118,3 @@ This prevents lockout when a user migrates from long-running local trusted usage
|
|||||||
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
|
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
|
||||||
- V1 contract: `doc/SPEC-implementation.md`
|
- V1 contract: `doc/SPEC-implementation.md`
|
||||||
- operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md`
|
- operator workflows: `doc/DEVELOPING.md` and `doc/CLI.md`
|
||||||
- invite/join state map: `doc/spec/invite-flow.md`
|
|
||||||
|
|||||||
@@ -43,19 +43,6 @@ This starts:
|
|||||||
|
|
||||||
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
|
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
|
||||||
|
|
||||||
Issue execution may also use project execution workspace policies and workspace runtime services for per-project worktrees, preview servers, and managed dev commands. Configure those through the project workspace/runtime surfaces rather than starting long-running unmanaged processes when a task needs a reusable service.
|
|
||||||
|
|
||||||
## Storybook
|
|
||||||
|
|
||||||
The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm storybook
|
|
||||||
pnpm build-storybook
|
|
||||||
```
|
|
||||||
|
|
||||||
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
|
|
||||||
|
|
||||||
Inspect or stop the current repo's managed dev runner:
|
Inspect or stop the current repo's managed dev runner:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -67,56 +54,18 @@ pnpm dev:stop
|
|||||||
|
|
||||||
Tailscale/private-auth dev mode:
|
Tailscale/private-auth dev mode:
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm dev --bind lan
|
|
||||||
```
|
|
||||||
|
|
||||||
This runs dev as `authenticated/private` with a private-network bind preset.
|
|
||||||
|
|
||||||
For Tailscale-only reachability on a detected tailnet address:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm dev --bind tailnet
|
|
||||||
```
|
|
||||||
|
|
||||||
Legacy aliases still map to the old broad private-network behavior:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --tailscale-auth
|
||||||
pnpm dev --authenticated-private
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This runs dev as `authenticated/private` and binds the server to `0.0.0.0` for private-network access.
|
||||||
|
|
||||||
Allow additional private hostnames (for example custom Tailscale hostnames):
|
Allow additional private hostnames (for example custom Tailscale hostnames):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test Commands
|
|
||||||
|
|
||||||
Use the cheap local default unless you are specifically working on browser flows:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test
|
|
||||||
```
|
|
||||||
|
|
||||||
`pnpm test` runs the Vitest suite only. For interactive Vitest watch mode use:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test:watch
|
|
||||||
```
|
|
||||||
|
|
||||||
Browser suites stay separate:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test:e2e
|
|
||||||
pnpm test:release-smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
These browser suites are intended for targeted local verification and CI, not the default agent/human test command.
|
|
||||||
|
|
||||||
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
|
|
||||||
|
|
||||||
## One-Command Local Run
|
## One-Command Local Run
|
||||||
|
|
||||||
For a first-time local install, you can bootstrap and run in one command:
|
For a first-time local install, you can bootstrap and run in one command:
|
||||||
@@ -157,27 +106,6 @@ See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`)
|
|||||||
|
|
||||||
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||||
|
|
||||||
## Local Instance Layout
|
|
||||||
|
|
||||||
Every local install keeps runtime state directly under the selected instance root:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.paperclip/instances/default/ # instance root
|
|
||||||
config.json # runtime config
|
|
||||||
.env # instance env file
|
|
||||||
db/ # embedded PostgreSQL data
|
|
||||||
data/
|
|
||||||
storage/ # local_disk uploads
|
|
||||||
backups/ # automatic DB backups
|
|
||||||
logs/
|
|
||||||
secrets/master.key # local_encrypted master key
|
|
||||||
workspaces/<agent-id>/ # default agent workspaces
|
|
||||||
projects/ # project execution workspaces
|
|
||||||
companies/<company-id>/codex-home/ # per-company codex_local home
|
|
||||||
```
|
|
||||||
|
|
||||||
`PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` override the home root and instance id respectively. `paperclipai onboard` echoes the resolved values in its banner (`Local home: <home> | instance: <id> | config: <path>`) so you can confirm where state will land before continuing.
|
|
||||||
|
|
||||||
## Database in Dev (Auto-Handled)
|
## Database in Dev (Auto-Handled)
|
||||||
|
|
||||||
For local development, leave `DATABASE_URL` unset.
|
For local development, leave `DATABASE_URL` unset.
|
||||||
@@ -185,7 +113,7 @@ The server will automatically use embedded PostgreSQL and persist data at:
|
|||||||
|
|
||||||
- `~/.paperclip/instances/default/db`
|
- `~/.paperclip/instances/default/db`
|
||||||
|
|
||||||
Override home or instance:
|
Override home and instance:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
|
PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
|
||||||
@@ -219,8 +147,6 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins
|
|||||||
|
|
||||||
If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary).
|
If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary).
|
||||||
|
|
||||||
Local adapters require their corresponding CLI/session setup on the machine running Paperclip. External adapters are installed through the adapter/plugin flow and should not require hardcoded imports in `server/` or `ui/`.
|
|
||||||
|
|
||||||
## Worktree-local Instances
|
## Worktree-local Instances
|
||||||
|
|
||||||
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
||||||
@@ -247,13 +173,9 @@ Seed modes:
|
|||||||
- `full` makes a full logical clone of the source instance
|
- `full` makes a full logical clone of the source instance
|
||||||
- `--no-seed` creates an empty isolated instance
|
- `--no-seed` creates an empty isolated instance
|
||||||
|
|
||||||
Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments.
|
|
||||||
|
|
||||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||||
|
|
||||||
`pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first.
|
Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development.
|
||||||
|
|
||||||
Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines.
|
|
||||||
|
|
||||||
That repo-local env also sets:
|
That repo-local env also sets:
|
||||||
|
|
||||||
@@ -262,8 +184,6 @@ That repo-local env also sets:
|
|||||||
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
|
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
|
||||||
|
|
||||||
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
|
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
|
||||||
Authenticated worktree servers also use the `PAPERCLIP_INSTANCE_ID` value to scope Better Auth cookie names.
|
|
||||||
Browser cookies are shared by host rather than port, so this prevents logging into one `127.0.0.1:<port>` worktree from replacing another worktree server's session cookie.
|
|
||||||
|
|
||||||
Print shell exports explicitly when needed:
|
Print shell exports explicitly when needed:
|
||||||
|
|
||||||
@@ -301,10 +221,10 @@ paperclipai worktree init --from-data-dir ~/.paperclip
|
|||||||
paperclipai worktree init --force
|
paperclipai worktree init --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install. Point `--from-config` at the instance config:
|
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
|
cd ~/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||||
pnpm paperclipai worktree init --force --seed-mode minimal \
|
pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||||
--name PAP-884-ai-commits-component \
|
--name PAP-884-ai-commits-component \
|
||||||
--from-config ~/.paperclip/instances/default/config.json
|
--from-config ~/.paperclip/instances/default/config.json
|
||||||
@@ -312,33 +232,6 @@ pnpm paperclipai worktree init --force --seed-mode minimal \
|
|||||||
|
|
||||||
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
||||||
|
|
||||||
For an already-created worktree where you want the CLI to decide whether to rebuild missing worktree metadata or just reseed the isolated DB, use `worktree repair`.
|
|
||||||
|
|
||||||
**`pnpm paperclipai worktree repair [options]`** — Repair the current linked worktree by default, or create/repair a named linked worktree under `.paperclip/worktrees/` when `--branch` is provided. The command never targets the primary checkout unless you explicitly pass `--branch`.
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|---|---|
|
|
||||||
| `--branch <name>` | Existing branch/worktree selector to repair, or a branch name to create under `.paperclip/worktrees` |
|
|
||||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
|
||||||
| `--from-config <path>` | Source config.json to seed from |
|
|
||||||
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
|
|
||||||
| `--from-instance <id>` | Source instance id when deriving the source config (default: `default`) |
|
|
||||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
|
||||||
| `--no-seed` | Repair metadata only when bootstrapping a missing worktree config |
|
|
||||||
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# From inside a linked worktree, rebuild missing .paperclip metadata and reseed it from the default instance.
|
|
||||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
|
||||||
pnpm paperclipai worktree repair
|
|
||||||
|
|
||||||
# From the primary checkout, create or repair a linked worktree for a branch under .paperclip/worktrees/.
|
|
||||||
cd /path/to/paperclip
|
|
||||||
pnpm paperclipai worktree repair --branch PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
||||||
|
|
||||||
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||||
@@ -442,9 +335,7 @@ If you set `DATABASE_URL`, the server will use that instead of embedded PostgreS
|
|||||||
|
|
||||||
## Automatic DB Backups
|
## Automatic DB Backups
|
||||||
|
|
||||||
Paperclip can run automatic logical database backups on a timer. These backups cover
|
Paperclip can run automatic DB backups on a timer. Defaults:
|
||||||
non-system database schemas, including migration history and plugin-owned database
|
|
||||||
schemas. Defaults:
|
|
||||||
|
|
||||||
- enabled
|
- enabled
|
||||||
- every 60 minutes
|
- every 60 minutes
|
||||||
@@ -472,10 +363,6 @@ Environment overrides:
|
|||||||
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
|
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
|
||||||
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
|
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
|
||||||
|
|
||||||
DB backups are not full instance filesystem backups. For full local disaster
|
|
||||||
recovery, also back up local storage files and the local encrypted secrets key if
|
|
||||||
those providers are enabled.
|
|
||||||
|
|
||||||
## Secrets in Dev
|
## Secrets in Dev
|
||||||
|
|
||||||
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
||||||
@@ -483,7 +370,6 @@ Agent env vars now support secret references. By default, secret values are stor
|
|||||||
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
||||||
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
||||||
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
||||||
- Back up the key file and database together; either one alone is not enough to restore local encrypted secrets.
|
|
||||||
|
|
||||||
Strict mode (recommended outside local trusted machines):
|
Strict mode (recommended outside local trusted machines):
|
||||||
|
|
||||||
@@ -492,20 +378,12 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
||||||
Authenticated deployments default strict mode on unless explicitly overridden.
|
|
||||||
|
|
||||||
CLI configuration support:
|
CLI configuration support:
|
||||||
|
|
||||||
- `pnpm paperclipai onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
- `pnpm paperclipai onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
||||||
- `pnpm paperclipai configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
- `pnpm paperclipai configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
||||||
- `pnpm paperclipai doctor` validates secrets adapter configuration, can create a missing local key file with `--repair`, and reports missing AWS Secrets Manager bootstrap env when that provider is selected.
|
- `pnpm paperclipai doctor` validates secrets adapter configuration and can create a missing local key file with `--repair`.
|
||||||
- Provider health is available at `GET /api/companies/:companyId/secret-providers/health` and reports local key permission warnings plus backup guidance.
|
|
||||||
|
|
||||||
Per-company provider vaults are configured in the board UI under
|
|
||||||
`Company Settings → Secrets → Provider vaults`, backed by
|
|
||||||
`/api/companies/{companyId}/secret-provider-configs`. The CLI does not own
|
|
||||||
vault lifecycle today. See `docs/deploy/secrets.md` (`Provider Vaults` section)
|
|
||||||
for the operator model.
|
|
||||||
|
|
||||||
Migration helper for existing inline env secrets:
|
Migration helper for existing inline env secrets:
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Paperclip is the command, communication, and control plane for a company of AI a
|
|||||||
- **Track work in real time** — see at any moment what every agent is working on
|
- **Track work in real time** — see at any moment what every agent is working on
|
||||||
- **Control costs** — token salary budgets per agent, spend tracking, burn rate
|
- **Control costs** — token salary budgets per agent, spend tracking, burn rate
|
||||||
- **Align to goals** — agents see how their work serves the bigger mission
|
- **Align to goals** — agents see how their work serves the bigger mission
|
||||||
- **Preserve work context** — comments, documents, work products, attachments, and company state stay attached to the work
|
- **Store company knowledge** — a shared brain for the organization
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -36,20 +36,17 @@ The central nervous system. Manages:
|
|||||||
- Agent registry and org chart
|
- Agent registry and org chart
|
||||||
- Task assignment and status
|
- Task assignment and status
|
||||||
- Budget and token spend tracking
|
- Budget and token spend tracking
|
||||||
- Issue comments, documents, work products, attachments, and company state
|
- Company knowledge base
|
||||||
- Goal hierarchy (company → team → agent → task)
|
- Goal hierarchy (company → team → agent → task)
|
||||||
- Heartbeat monitoring — know when agents are alive, idle, or stuck
|
- Heartbeat monitoring — know when agents are alive, idle, or stuck
|
||||||
|
|
||||||
It also enforces execution-control semantics such as single-assignee issues, atomic checkout and execution locks, blockers, recovery issues, and workspace/runtime controls.
|
|
||||||
|
|
||||||
### 2. Execution Services (adapters)
|
### 2. Execution Services (adapters)
|
||||||
|
|
||||||
Agents run externally and report into the control plane. Adapters connect different execution environments and define how a heartbeat is invoked, observed, and cancelled:
|
Agents run externally and report into the control plane. An agent is just Python code that gets kicked off and does work. Adapters connect different execution environments:
|
||||||
|
|
||||||
- **Local CLI/session adapters** — built-in adapters for tools such as Claude Code, Codex, Gemini, OpenCode, Pi, and Cursor
|
- **OpenClaw** — initial adapter target
|
||||||
- **HTTP/process-style adapters** — command or webhook/API integrations for custom runtimes
|
- **Heartbeat loop** — simple custom Python that loops, checks in, does work
|
||||||
- **OpenClaw gateway** — integration for OpenClaw-style remote agents
|
- **Others** — any runtime that can call an API
|
||||||
- **External adapter plugins** — dynamically loaded adapters installed outside the core app
|
|
||||||
|
|
||||||
The control plane doesn't run agents. It orchestrates them. Agents run wherever they run and phone home.
|
The control plane doesn't run agents. It orchestrates them. Agents run wherever they run and phone home.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Use this exact checklist.
|
|||||||
1. Start Paperclip in auth mode.
|
1. Start Paperclip in auth mode.
|
||||||
```bash
|
```bash
|
||||||
cd <paperclip-repo-root>
|
cd <paperclip-repo-root>
|
||||||
pnpm dev --bind lan
|
pnpm dev --tailscale-auth
|
||||||
```
|
```
|
||||||
Then verify:
|
Then verify:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ Then you define who reports to the CEO: a CTO managing programmers, a CMO managi
|
|||||||
|
|
||||||
### Agent Execution
|
### Agent Execution
|
||||||
|
|
||||||
Paperclip supports several ways to run an agent's heartbeat:
|
There are two fundamental modes for running an agent's heartbeat:
|
||||||
|
|
||||||
1. **Local CLI/session adapters** — Paperclip starts or resumes local coding-tool sessions such as Claude Code, Codex, Gemini, OpenCode, Pi, and Cursor, then tracks the run.
|
1. **Run a command** — Paperclip kicks off a process (shell command, Python script, etc.) and tracks it. The heartbeat is "execute this and monitor it."
|
||||||
2. **Run a command** — Paperclip kicks off a process (shell command, Python script, etc.) and tracks it. The heartbeat is "execute this and monitor it."
|
2. **Fire and forget a request** — Paperclip sends a webhook/API call to an externally running agent. The heartbeat is "notify this agent to wake up." (OpenClaw hooks work this way.)
|
||||||
3. **Fire and forget a request** — Paperclip sends a webhook/API call to an externally running agent. The heartbeat is "notify this agent to wake up." OpenClaw-style hooks work this way.
|
|
||||||
4. **External adapter plugins** — Paperclip loads adapter packages through the plugin/adapter flow so self-hosted installs can add runtimes without hardcoding them in core.
|
|
||||||
|
|
||||||
Agent runs can use project and execution workspaces, managed runtime services such as preview/dev servers, adapter-specific session state, and HTTP/webhook-style execution. We provide sensible defaults, but the adapter is still the boundary: if a runtime can be invoked, observed, and authorized, Paperclip can coordinate it.
|
We provide sensible defaults — a default agent that shells out to Claude Code or Codex with your configuration, remembers session IDs, runs basic scripts. But you can plug in anything.
|
||||||
|
|
||||||
### Task Management
|
### Task Management
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ I am researching the Facebook ads Granola uses (current task)
|
|||||||
|
|
||||||
Tasks have parentage. Every task exists in service of a parent task, all the way up to the company goal. This is what keeps autonomous agents aligned — they can always answer "why am I doing this?"
|
Tasks have parentage. Every task exists in service of a parent task, all the way up to the company goal. This is what keeps autonomous agents aligned — they can always answer "why am I doing this?"
|
||||||
|
|
||||||
The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks.
|
More detailed task structure TBD.
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|
||||||
@@ -117,7 +115,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 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 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 build enterprise-grade RBAC first. The current V1 spec still treats multi-board governance and fine-grained human permissions as out of scope, so the first multi-user version should be coarse and 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 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.
|
- 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.
|
||||||
|
|
||||||
@@ -138,14 +136,11 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
|||||||
5. **Output-first**
|
5. **Output-first**
|
||||||
Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR.
|
Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR.
|
||||||
|
|
||||||
6. **Execution visibility without log worship**
|
6. **Local-first, cloud-ready**
|
||||||
Active runs, recovery issues, productivity review states, blockers, and work products should be first-class surfaces. Raw transcripts are available when needed, but they are not the primary product surface.
|
|
||||||
|
|
||||||
7. **Local-first, cloud-ready**
|
|
||||||
The mental model should not change between local solo use and shared/private or public/cloud deployment.
|
The mental model should not change between local solo use and shared/private or public/cloud deployment.
|
||||||
|
|
||||||
8. **Safe autonomy**
|
7. **Safe autonomy**
|
||||||
Auto mode is allowed; hidden token burn is not.
|
Auto mode is allowed; hidden token burn is not.
|
||||||
|
|
||||||
9. **Thin core, rich edges**
|
8. **Thin core, rich edges**
|
||||||
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
|
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
|
||||||
|
|||||||
@@ -115,6 +115,38 @@ If the first real publish returns npm `E404`, check npm-side prerequisites befor
|
|||||||
- The initial publish must include `--access public` for a public scoped package.
|
- The initial publish must include `--access public` for a public scoped package.
|
||||||
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
||||||
|
|
||||||
|
### Manual first publish for `@paperclipai/mcp-server`
|
||||||
|
|
||||||
|
If you need to publish only the MCP server package once by hand, use:
|
||||||
|
|
||||||
|
- `@paperclipai/mcp-server`
|
||||||
|
|
||||||
|
Recommended flow from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# optional sanity check: this 404s until the first publish exists
|
||||||
|
npm view @paperclipai/mcp-server version
|
||||||
|
|
||||||
|
# make sure the build output is fresh
|
||||||
|
pnpm --filter @paperclipai/mcp-server build
|
||||||
|
|
||||||
|
# confirm your local npm auth before the real publish
|
||||||
|
npm whoami
|
||||||
|
|
||||||
|
# safe preview of the exact publish payload
|
||||||
|
cd packages/mcp-server
|
||||||
|
pnpm publish --dry-run --no-git-checks --access public
|
||||||
|
|
||||||
|
# real publish
|
||||||
|
pnpm publish --no-git-checks --access public
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Publish from `packages/mcp-server/`, not the repo root.
|
||||||
|
- If `npm view @paperclipai/mcp-server version` already returns the same version that is in [`packages/mcp-server/package.json`](../packages/mcp-server/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
|
||||||
|
- The same npm-side prerequisites apply as above: valid npm auth, permission to publish to the `@paperclipai` scope, `--access public`, and the required publish auth/2FA policy.
|
||||||
|
|
||||||
## Version formats
|
## Version formats
|
||||||
|
|
||||||
Paperclip uses calendar versions:
|
Paperclip uses calendar versions:
|
||||||
@@ -143,13 +175,6 @@ This keeps the default install path unchanged while allowing explicit installs w
|
|||||||
npx paperclipai@canary onboard
|
npx paperclipai@canary onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
The release script now verifies two things after a canary publish:
|
|
||||||
|
|
||||||
- the `canary` dist-tag resolves to the version that was just published
|
|
||||||
- every published internal `@paperclipai/*` dependency referenced by that manifest exists on npm
|
|
||||||
|
|
||||||
It also treats `latest -> canary` as a failure by default, because npm metadata can otherwise leave the default install path pointing at an unreleased canary dependency graph. Only pass `./scripts/release.sh canary --allow-canary-latest` when that `latest` behavior is explicitly intended.
|
|
||||||
|
|
||||||
### Stable
|
### Stable
|
||||||
|
|
||||||
Stable publishes use the npm dist-tag `latest`.
|
Stable publishes use the npm dist-tag `latest`.
|
||||||
@@ -176,58 +201,6 @@ That means:
|
|||||||
|
|
||||||
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
|
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
|
||||||
|
|
||||||
## Release enrollment for new public packages
|
|
||||||
|
|
||||||
Paperclip does not auto-publish every non-private workspace package anymore.
|
|
||||||
CI publishing is controlled by [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json).
|
|
||||||
|
|
||||||
When you add a new public package:
|
|
||||||
|
|
||||||
1. add it to the manifest and decide whether CI should publish it immediately
|
|
||||||
2. if CI should publish it, bootstrap the package on npm before merge
|
|
||||||
3. if CI should not publish it yet, keep `"publishFromCi": false`
|
|
||||||
4. only enable `"publishFromCi": true` after npm trusted publishing is configured for that package
|
|
||||||
|
|
||||||
PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`.
|
|
||||||
|
|
||||||
### One-time bootstrap sequence for a new package
|
|
||||||
|
|
||||||
The first publish of a brand-new package still needs one human maintainer with npm write access.
|
|
||||||
After that, trusted publishing can take over.
|
|
||||||
|
|
||||||
Example for `@paperclipai/adapter-acpx-local` from the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# safe preview
|
|
||||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local
|
|
||||||
|
|
||||||
# one-time first publish from an authenticated maintainer machine
|
|
||||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper script:
|
|
||||||
|
|
||||||
- checks that the package does not already exist on npm
|
|
||||||
- builds the target package unless `--skip-build` is passed
|
|
||||||
- runs `npm pack --dry-run` in the package directory
|
|
||||||
- only runs the real `npm publish --access public` when `--publish --otp <code>` is provided
|
|
||||||
|
|
||||||
For the real `--publish` step, the maintainer machine must already be authenticated to npm.
|
|
||||||
If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper.
|
|
||||||
That local human auth is fine for the one-time bootstrap publish; we just do not want the same auth model inside CI.
|
|
||||||
The helper now requires `--otp <code>` up front for `--publish`, so it fails before the real publish attempt if the one-time password is missing.
|
|
||||||
|
|
||||||
After that first publish succeeds:
|
|
||||||
|
|
||||||
1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local`
|
|
||||||
2. go to `Settings` → `Trusted publishing`
|
|
||||||
3. add repository `paperclipai/paperclip`
|
|
||||||
4. set workflow filename to `release.yml`
|
|
||||||
5. optionally go to `Settings` → `Publishing access` and enable `Require two-factor authentication and disallow tokens`
|
|
||||||
6. keep `publishFromCi: true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
|
||||||
|
|
||||||
Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step is only the first package creation on npm.
|
|
||||||
|
|
||||||
## Rollback model
|
## Rollback model
|
||||||
|
|
||||||
Rollback does not unpublish anything.
|
Rollback does not unpublish anything.
|
||||||
|
|||||||
@@ -67,27 +67,6 @@ Why:
|
|||||||
- the single `release.yml` workflow handles both canary and stable publishing
|
- the single `release.yml` workflow handles both canary and stable publishing
|
||||||
- GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side
|
- GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side
|
||||||
|
|
||||||
### 2.2.1. Newly added public packages need a bootstrap phase
|
|
||||||
|
|
||||||
Trusted publishing is configured on the npm package itself, not at the repo scope.
|
|
||||||
That means a brand-new public package must not be auto-enrolled into CI publishing until its npm package exists and its trusted publisher has been configured.
|
|
||||||
|
|
||||||
Repo policy:
|
|
||||||
|
|
||||||
1. add every non-private package to [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
|
||||||
2. set `"publishFromCi": true` only when CI is expected to publish that package
|
|
||||||
3. if the package is not ready for CI publishing yet, keep `"publishFromCi": false`
|
|
||||||
4. complete the package bootstrap before merging any PR that changes a release-enabled new package
|
|
||||||
|
|
||||||
Bootstrap sequence for a new package:
|
|
||||||
|
|
||||||
1. publish the package once from a trusted maintainer machine using normal npm auth
|
|
||||||
2. open that package on npm and add the `paperclipai/paperclip` trusted publisher for `.github/workflows/release.yml`
|
|
||||||
3. rerun or dry-run the release flow as needed to confirm CI publishing now works
|
|
||||||
4. only then enable `"publishFromCi": true`
|
|
||||||
|
|
||||||
PR CI enforces this by checking changed release-enabled package manifests against npm. That keeps `master` canary publishing healthy while preserving the no-long-lived-token model for normal CI releases.
|
|
||||||
|
|
||||||
### 2.3. Verify trusted publishing before removing old auth
|
### 2.3. Verify trusted publishing before removing old auth
|
||||||
|
|
||||||
After the workflows are live:
|
After the workflows are live:
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ It:
|
|||||||
- verifies the pushed commit
|
- verifies the pushed commit
|
||||||
- computes the canary version for the current UTC date
|
- computes the canary version for the current UTC date
|
||||||
- publishes under npm dist-tag `canary`
|
- publishes under npm dist-tag `canary`
|
||||||
- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm
|
|
||||||
- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional
|
|
||||||
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
|
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
|
||||||
|
|
||||||
Users install canaries with:
|
Users install canaries with:
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
# AWS Secrets Manager Provider
|
|
||||||
|
|
||||||
Operational contract for the hosted `aws_secrets_manager` secret provider used by Paperclip Cloud.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS.
|
|
||||||
- Source of truth for secret values is AWS Secrets Manager, not Postgres.
|
|
||||||
- Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution.
|
|
||||||
- AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets.
|
|
||||||
- Remote import for existing AWS secrets is metadata-only. Preview/import uses
|
|
||||||
AWS inventory metadata and creates Paperclip external references; it does not
|
|
||||||
copy plaintext into Paperclip.
|
|
||||||
- Per-company AWS provider vaults (named instances of `aws_secrets_manager`
|
|
||||||
with their own region, namespace, prefix, KMS key id, and tags) are managed
|
|
||||||
in the board UI under `Company Settings → Secrets → Provider vaults`. See
|
|
||||||
[Provider Vaults](../docs/deploy/secrets.md#provider-vaults) for the operator
|
|
||||||
model and [Provider Vaults API](../docs/api/secrets.md#provider-vaults) for
|
|
||||||
the routes. The bootstrap trust model in this document still applies — vault
|
|
||||||
config carries non-sensitive routing metadata only, never AWS credentials.
|
|
||||||
|
|
||||||
## Bootstrap Trust Model
|
|
||||||
|
|
||||||
The AWS provider has a chicken-and-egg boundary: Paperclip cannot use
|
|
||||||
`company_secrets` to unlock the AWS provider that stores those secrets. The
|
|
||||||
initial AWS trust must exist before the Paperclip server starts.
|
|
||||||
|
|
||||||
Allowed bootstrap locations:
|
|
||||||
|
|
||||||
- Infrastructure IAM or workload identity attached to the Paperclip server
|
|
||||||
runtime.
|
|
||||||
- Process environment or orchestrator secret store used to start the Paperclip
|
|
||||||
server.
|
|
||||||
- Local AWS SDK sources such as `AWS_PROFILE`, AWS SSO/shared config, web
|
|
||||||
identity, container metadata, or instance metadata.
|
|
||||||
- Short-lived shell credentials for local development only.
|
|
||||||
|
|
||||||
Do not ask operators to paste AWS root credentials or long-lived IAM user access
|
|
||||||
keys into the Paperclip board UI. Do not store those bootstrap keys in
|
|
||||||
`company_secrets`.
|
|
||||||
|
|
||||||
## Paperclip Cloud Bootstrap
|
|
||||||
|
|
||||||
Paperclip Cloud must provision the AWS backing resources before any board user
|
|
||||||
can create AWS-backed company secrets:
|
|
||||||
|
|
||||||
1. Create or select the deployment KMS key.
|
|
||||||
2. Create the Paperclip server runtime role for the deployment.
|
|
||||||
3. Attach a minimum IAM policy scoped to the deployment Secrets Manager prefix
|
|
||||||
and the configured KMS key.
|
|
||||||
4. Configure the server runtime with the non-secret provider environment
|
|
||||||
variables below.
|
|
||||||
5. Run `paperclipai doctor` or the provider health endpoint from the deployed
|
|
||||||
runtime and confirm that the provider reports the expected region, prefix,
|
|
||||||
deployment id, KMS setting, and AWS SDK credential source.
|
|
||||||
|
|
||||||
Once this is in place, the board UI can create Paperclip-managed AWS secrets and
|
|
||||||
Paperclip will write them under the deployment/company namespace.
|
|
||||||
|
|
||||||
## Self-Hosted And Local Bootstrap
|
|
||||||
|
|
||||||
Self-hosted AWS deployments should use the AWS SDK default credential provider
|
|
||||||
chain. Preferred sources are role-based:
|
|
||||||
|
|
||||||
- EC2 instance profile.
|
|
||||||
- ECS task role.
|
|
||||||
- EKS IRSA or another OIDC web identity role.
|
|
||||||
- AWS SSO/shared config via `AWS_PROFILE`.
|
|
||||||
|
|
||||||
Local development can use:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
aws sso login --profile paperclip-dev
|
|
||||||
AWS_PROFILE=paperclip-dev \
|
|
||||||
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager \
|
|
||||||
PAPERCLIP_SECRETS_AWS_REGION=us-east-1 \
|
|
||||||
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=dev-local \
|
|
||||||
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-... \
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Temporary `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` environment credentials
|
|
||||||
are acceptable only as a local break-glass or short-lived test source. They
|
|
||||||
should not be written to Paperclip config, committed to `.env` files, stored in
|
|
||||||
`company_secrets`, or used as the default Paperclip Cloud bootstrap path.
|
|
||||||
|
|
||||||
## Deployment Config
|
|
||||||
|
|
||||||
Required environment variables:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager
|
|
||||||
PAPERCLIP_SECRETS_AWS_REGION=us-east-1
|
|
||||||
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=prod-us-1
|
|
||||||
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-...
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional environment variables:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
PAPERCLIP_SECRETS_AWS_PREFIX=paperclip
|
|
||||||
PAPERCLIP_SECRETS_AWS_ENVIRONMENT=production
|
|
||||||
PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER=paperclip
|
|
||||||
PAPERCLIP_SECRETS_AWS_ENDPOINT=
|
|
||||||
PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS=30
|
|
||||||
```
|
|
||||||
|
|
||||||
Naming convention for Paperclip-managed secrets:
|
|
||||||
|
|
||||||
```text
|
|
||||||
paperclip/{deploymentId}/{companyId}/{secretKey}
|
|
||||||
```
|
|
||||||
|
|
||||||
Tag set for Paperclip-managed secrets:
|
|
||||||
|
|
||||||
- `paperclip:managed-by=paperclip`
|
|
||||||
- `paperclip:provider-owner=<owner tag>`
|
|
||||||
- `paperclip:deployment-id=<deployment id>`
|
|
||||||
- `paperclip:company-id=<company id>`
|
|
||||||
- `paperclip:secret-key=<secret key>`
|
|
||||||
- `paperclip:environment=<environment tag>`
|
|
||||||
|
|
||||||
## IAM And KMS Assumptions
|
|
||||||
|
|
||||||
Launch posture:
|
|
||||||
|
|
||||||
- One Paperclip app role per deployment.
|
|
||||||
- One deployment-scoped KMS key per deployment at launch.
|
|
||||||
- Future per-company KMS keys remain compatible because Paperclip stores provider refs and version metadata separately from values.
|
|
||||||
|
|
||||||
Minimum IAM boundary:
|
|
||||||
|
|
||||||
- Allow `secretsmanager:CreateSecret`, `PutSecretValue`, `GetSecretValue`, and `DeleteSecret`.
|
|
||||||
- Scope resources to the deployment prefix:
|
|
||||||
|
|
||||||
```text
|
|
||||||
arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*
|
|
||||||
```
|
|
||||||
|
|
||||||
- Allow `kms:Encrypt`, `kms:Decrypt`, `kms:GenerateDataKey`, and `kms:DescribeKey` for the configured deployment CMK.
|
|
||||||
- Deny wildcard access outside the deployment prefix.
|
|
||||||
- Prefer workload identity / role-based auth. Do not store AWS credentials inline in Paperclip config.
|
|
||||||
|
|
||||||
Example minimum policy shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Sid": "PaperclipDeploymentSecrets",
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": [
|
|
||||||
"secretsmanager:CreateSecret",
|
|
||||||
"secretsmanager:PutSecretValue",
|
|
||||||
"secretsmanager:GetSecretValue",
|
|
||||||
"secretsmanager:DeleteSecret"
|
|
||||||
],
|
|
||||||
"Resource": "arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Sid": "PaperclipDeploymentKms",
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": [
|
|
||||||
"kms:Encrypt",
|
|
||||||
"kms:Decrypt",
|
|
||||||
"kms:GenerateDataKey",
|
|
||||||
"kms:DescribeKey"
|
|
||||||
],
|
|
||||||
"Resource": "arn:aws:kms:<region>:<account-id>:key/<key-id>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Operational expectation:
|
|
||||||
|
|
||||||
- Paperclip-managed secrets may be deleted only by Paperclip or an operator with equivalent break-glass access.
|
|
||||||
- External references may resolve through Paperclip runtime, but Paperclip should not delete the external secret resource.
|
|
||||||
|
|
||||||
## Remote Import Inventory IAM
|
|
||||||
|
|
||||||
Remote import preview needs one additional AWS permission:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Sid": "PaperclipRemoteSecretInventory",
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": "secretsmanager:ListSecrets",
|
|
||||||
"Resource": "*"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is intentionally separate from the managed create/rotate/delete policy.
|
|
||||||
AWS treats `ListSecrets` as an account/Region inventory action; do not document
|
|
||||||
secret ARNs, names, tags, or AWS request filters as an IAM boundary for it. Use
|
|
||||||
`Resource: "*"` and decide whether inventory exposure is acceptable for the AWS
|
|
||||||
account and Region behind each provider vault.
|
|
||||||
|
|
||||||
Remote import preview/import must not call:
|
|
||||||
|
|
||||||
- `secretsmanager:GetSecretValue`
|
|
||||||
- `secretsmanager:BatchGetSecretValue`
|
|
||||||
- `kms:Decrypt`
|
|
||||||
|
|
||||||
Those permissions are only needed later when a bound runtime resolves an
|
|
||||||
imported external reference. For imported refs, scope read permissions to the
|
|
||||||
operator-approved external prefixes that Paperclip is allowed to consume:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Sid": "PaperclipResolveImportedExternalReferences",
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": "secretsmanager:GetSecretValue",
|
|
||||||
"Resource": [
|
|
||||||
"arn:aws:secretsmanager:<region>:<account-id>:secret:<approved-external-prefix>/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If selected external secrets use customer-managed KMS keys, also grant
|
|
||||||
`kms:Decrypt` and `kms:DescribeKey` on those keys. Keep managed write/delete
|
|
||||||
permissions scoped to `paperclip/<deployment-id>/*`; do not broaden them for
|
|
||||||
remote import.
|
|
||||||
|
|
||||||
Safe scoping guidance:
|
|
||||||
|
|
||||||
- Prefer one Paperclip runtime role per environment/account.
|
|
||||||
- Point provider vaults at the intended AWS account and Region instead of a
|
|
||||||
broad central admin role.
|
|
||||||
- Enable `ListSecrets` only in accounts where inventory exposure is acceptable.
|
|
||||||
- Keep preview/import board-only; agent API keys must not call these routes.
|
|
||||||
- Treat AWS tag/name filters as search UX only, not permission enforcement.
|
|
||||||
|
|
||||||
Paperclip also blocks importing refs under its own managed namespace as
|
|
||||||
external references. Use the Paperclip-managed flow for
|
|
||||||
`paperclip/{deploymentId}/{companyId}/{secretKey}` resources.
|
|
||||||
|
|
||||||
## Existing AWS Secrets
|
|
||||||
|
|
||||||
V1 keeps existing AWS Secrets Manager entries as **linked external references**, not adopted
|
|
||||||
Paperclip-managed resources.
|
|
||||||
|
|
||||||
Use the Paperclip-managed flow when Paperclip should create and rotate the value. The AWS
|
|
||||||
secret name is derived from deployment and company scope:
|
|
||||||
|
|
||||||
```text
|
|
||||||
paperclip/{deploymentId}/{companyId}/{secretKey}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the external-reference flow when the secret already exists at an operator-owned path such
|
|
||||||
as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/paperclip-bench/anthropic_api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
In that mode Paperclip stores only the path or ARN, resolves it at runtime, and records
|
|
||||||
redacted access events. Operators rotate the actual value in AWS. Update the Paperclip
|
|
||||||
reference only when the AWS path, ARN, or pinned provider version changes.
|
|
||||||
|
|
||||||
Paperclip does not currently offer an "adopt existing AWS secret" flow that takes over future
|
|
||||||
`PutSecretValue` writes for an arbitrary existing secret. Adding that later requires explicit
|
|
||||||
confirmation UX, scope validation, expected Paperclip tags, and security/cloud-ops review.
|
|
||||||
|
|
||||||
## Data Custody
|
|
||||||
|
|
||||||
- Paperclip stores `externalRef`, `providerVersionRef`, provider id, fingerprint hash, status, and binding metadata.
|
|
||||||
- Paperclip does not store AWS secret plaintext in `company_secret_versions.material`.
|
|
||||||
- Runtime resolution fetches the value from AWS only when a bound consumer needs it.
|
|
||||||
|
|
||||||
## Rotation Runbook
|
|
||||||
|
|
||||||
Manual Paperclip-managed rotation:
|
|
||||||
|
|
||||||
1. Write the new value through the Paperclip secret rotate flow.
|
|
||||||
2. Paperclip creates a new AWS secret version with `PutSecretValue`.
|
|
||||||
3. Paperclip records the new `providerVersionRef` in `company_secret_versions`.
|
|
||||||
4. Re-run or restart affected workloads that consume `latest`, or pin consumers to a specific Paperclip version before rollout when you need staged release safety.
|
|
||||||
|
|
||||||
Guidance:
|
|
||||||
|
|
||||||
- Prefer pinned Paperclip secret versions for risky rollouts.
|
|
||||||
- Treat provider-native automatic rotation as a later enhancement; current V1 flow is explicit create-new-version plus controlled rollout.
|
|
||||||
|
|
||||||
## Backup And Restore Runbook
|
|
||||||
|
|
||||||
What must survive:
|
|
||||||
|
|
||||||
- Paperclip database metadata for secret ownership, bindings, status, and provider version refs.
|
|
||||||
- AWS Secrets Manager namespace under the configured deployment prefix.
|
|
||||||
- The configured KMS key and its decrypt permissions.
|
|
||||||
|
|
||||||
Restore checklist:
|
|
||||||
|
|
||||||
1. Restore Paperclip database metadata.
|
|
||||||
2. Confirm the same AWS Secrets Manager namespace still exists.
|
|
||||||
3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix.
|
|
||||||
4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`.
|
|
||||||
5. Run the live smoke below or a targeted runtime secret resolution test.
|
|
||||||
|
|
||||||
## Provider Outage Runbook
|
|
||||||
|
|
||||||
Symptoms:
|
|
||||||
|
|
||||||
- Secret create/rotate/resolve operations fail with AWS provider errors.
|
|
||||||
- Agent runs fail before adapter invocation on required secret resolution.
|
|
||||||
- Remote import preview fails to list AWS inventory.
|
|
||||||
|
|
||||||
Immediate actions:
|
|
||||||
|
|
||||||
1. Confirm AWS regional health and Secrets Manager availability.
|
|
||||||
2. Confirm the runtime role still has `GetSecretValue` and KMS decrypt permissions.
|
|
||||||
3. Check for accidental prefix, region, deployment id, or KMS key config drift.
|
|
||||||
4. Retry a single resolution after AWS service health is green.
|
|
||||||
5. If outage persists, pause high-risk runs that require secret access rather than churning retries.
|
|
||||||
|
|
||||||
Remote import-specific actions:
|
|
||||||
|
|
||||||
- Missing list permission: add `secretsmanager:ListSecrets` with
|
|
||||||
`Resource: "*"` only when inventory import is approved for that vault's
|
|
||||||
AWS account and Region.
|
|
||||||
- Throttling: narrow the search, wait briefly, and retry with backoff. Avoid
|
|
||||||
full-account enumeration.
|
|
||||||
- Invalid or stale cursor: refresh the preview and discard the old
|
|
||||||
`NextToken`.
|
|
||||||
- Large account: load pages intentionally, keep one in-flight preview request
|
|
||||||
per vault/search, and do not run background full-account crawls.
|
|
||||||
- Runtime read failure after import: verify `GetSecretValue` and KMS decrypt
|
|
||||||
on the selected external secret. Visibility in `ListSecrets` does not prove
|
|
||||||
read permission.
|
|
||||||
|
|
||||||
## Incident Response Runbook
|
|
||||||
|
|
||||||
Potential incidents:
|
|
||||||
|
|
||||||
- Cross-company access caused by IAM scoping drift.
|
|
||||||
- KMS policy drift causing decrypt failures or over-broad access.
|
|
||||||
- Suspected secret exposure in logs, transcripts, or downstream agent output.
|
|
||||||
|
|
||||||
Response steps:
|
|
||||||
|
|
||||||
1. Stop or pause affected Paperclip runs.
|
|
||||||
2. Audit recent Paperclip secret access events for impacted secret ids and consumers.
|
|
||||||
3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`,
|
|
||||||
`PutSecretValue`, and `DeleteSecret` calls on the relevant vault account,
|
|
||||||
Region, deployment prefix, and approved external prefixes.
|
|
||||||
4. Rotate impacted secrets in AWS through Paperclip-managed versioning.
|
|
||||||
5. Re-scope IAM and KMS policies before resuming normal traffic.
|
|
||||||
6. If a value may have reached an agent transcript or external system, treat it as exposed and rotate immediately.
|
|
||||||
|
|
||||||
## Optional Live Smoke
|
|
||||||
|
|
||||||
This is safe to skip locally. Run it only against a dedicated AWS test namespace.
|
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
|
|
||||||
- AWS credentials or workload identity with the deployment-scoped IAM permissions above.
|
|
||||||
- `PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager`
|
|
||||||
- The required `PAPERCLIP_SECRETS_AWS_*` environment variables set.
|
|
||||||
|
|
||||||
Suggested smoke:
|
|
||||||
|
|
||||||
1. Create a test secret through the Paperclip board or API under a throwaway company.
|
|
||||||
2. Confirm the resulting AWS secret name matches `paperclip/{deploymentId}/{companyId}/{secretKey}`.
|
|
||||||
3. Rotate the secret once and confirm a new `providerVersionRef` appears in Paperclip metadata.
|
|
||||||
4. Resolve the secret through a bound runtime path, not by adding a general-purpose reveal endpoint.
|
|
||||||
5. Delete the throwaway secret and confirm AWS schedules deletion with the configured recovery window.
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Paperclip V1 Implementation Spec
|
# Paperclip V1 Implementation Spec
|
||||||
|
|
||||||
Status: Implementation contract for first release (V1)
|
Status: Implementation contract for first release (V1)
|
||||||
Date: 2026-04-28
|
Date: 2026-02-17
|
||||||
Audience: Product, engineering, and agent-integration authors
|
Audience: Product, engineering, and agent-integration authors
|
||||||
Source inputs: `GOAL.md`, `PRODUCT.md`, `SPEC.md`, `DATABASE.md`, current monorepo code
|
Source inputs: `GOAL.md`, `PRODUCT.md`, `SPEC.md`, `DATABASE.md`, current monorepo code
|
||||||
|
|
||||||
@@ -37,9 +37,8 @@ These decisions close open questions from `SPEC.md` for V1.
|
|||||||
| Visibility | Full visibility to board and all agents in same company |
|
| Visibility | Full visibility to board and all agents in same company |
|
||||||
| Communication | Tasks + comments only (no separate chat system) |
|
| Communication | Tasks + comments only (no separate chat system) |
|
||||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
| Recovery | No automatic reassignment; work recovery stays manual/explicit |
|
||||||
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
|
| Agent adapters | Built-in `process` and `http` adapters |
|
||||||
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
|
|
||||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||||
| Budget period | Monthly UTC calendar window |
|
| Budget period | Monthly UTC calendar window |
|
||||||
| Budget enforcement | Soft alerts + hard limit auto-pause |
|
| Budget enforcement | Soft alerts + hard limit auto-pause |
|
||||||
@@ -74,7 +73,7 @@ V1 implementation extends this baseline into a company-centric, governance-aware
|
|||||||
|
|
||||||
## 5.2 Out of Scope (V1)
|
## 5.2 Out of Scope (V1)
|
||||||
|
|
||||||
- Cloud-grade plugin marketplace/distribution beyond the local/self-hosted plugin runtime
|
- Plugin framework and third-party extension SDK
|
||||||
- Revenue/expense accounting beyond model/token costs
|
- Revenue/expense accounting beyond model/token costs
|
||||||
- Knowledge base subsystem
|
- Knowledge base subsystem
|
||||||
- Public marketplace (ClipHub)
|
- Public marketplace (ClipHub)
|
||||||
@@ -124,16 +123,6 @@ Human auth tables (`users`, `sessions`, and provider-specific auth artifacts) ar
|
|||||||
- `name` text not null
|
- `name` text not null
|
||||||
- `description` text null
|
- `description` text null
|
||||||
- `status` enum: `active | paused | archived`
|
- `status` enum: `active | paused | archived`
|
||||||
- `pause_reason` text null
|
|
||||||
- `paused_at` timestamptz null
|
|
||||||
- `issue_prefix` text not null
|
|
||||||
- `issue_counter` int not null
|
|
||||||
- `budget_monthly_cents` int not null default 0
|
|
||||||
- `spent_monthly_cents` int not null default 0
|
|
||||||
- `attachment_max_bytes` int not null
|
|
||||||
- `require_board_approval_for_new_agents` boolean not null default false
|
|
||||||
- feedback sharing consent fields
|
|
||||||
- branding fields such as `brand_color`
|
|
||||||
|
|
||||||
Invariant: every business record belongs to exactly one company.
|
Invariant: every business record belongs to exactly one company.
|
||||||
|
|
||||||
@@ -144,21 +133,15 @@ Invariant: every business record belongs to exactly one company.
|
|||||||
- `name` text not null
|
- `name` text not null
|
||||||
- `role` text not null
|
- `role` text not null
|
||||||
- `title` text null
|
- `title` text null
|
||||||
- `icon` text null
|
- `status` enum: `active | paused | idle | running | error | terminated`
|
||||||
- `status` enum: `active | paused | idle | running | error | pending_approval | terminated`
|
|
||||||
- `reports_to` uuid fk `agents.id` null
|
- `reports_to` uuid fk `agents.id` null
|
||||||
- `capabilities` text null
|
- `capabilities` text null
|
||||||
- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway`
|
- `adapter_type` enum: `process | http`
|
||||||
- `adapter_config` jsonb not null
|
- `adapter_config` jsonb not null
|
||||||
- `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config
|
|
||||||
- `default_environment_id` uuid fk `environments.id` null
|
|
||||||
- `context_mode` enum: `thin | fat` default `thin`
|
- `context_mode` enum: `thin | fat` default `thin`
|
||||||
- `budget_monthly_cents` int not null default 0
|
- `budget_monthly_cents` int not null default 0
|
||||||
- `spent_monthly_cents` int not null default 0
|
- `spent_monthly_cents` int not null default 0
|
||||||
- pause fields: `pause_reason`, `paused_at`
|
|
||||||
- `permissions` jsonb not null default `{}`
|
|
||||||
- `last_heartbeat_at` timestamptz null
|
- `last_heartbeat_at` timestamptz null
|
||||||
- `metadata` jsonb null
|
|
||||||
|
|
||||||
Invariants:
|
Invariants:
|
||||||
|
|
||||||
@@ -212,7 +195,6 @@ Invariant:
|
|||||||
- `id` uuid pk
|
- `id` uuid pk
|
||||||
- `company_id` uuid fk not null
|
- `company_id` uuid fk not null
|
||||||
- `project_id` uuid fk `projects.id` null
|
- `project_id` uuid fk `projects.id` null
|
||||||
- `project_workspace_id` uuid fk `project_workspaces.id` null
|
|
||||||
- `goal_id` uuid fk `goals.id` null
|
- `goal_id` uuid fk `goals.id` null
|
||||||
- `parent_id` uuid fk `issues.id` null
|
- `parent_id` uuid fk `issues.id` null
|
||||||
- `title` text not null
|
- `title` text not null
|
||||||
@@ -220,22 +202,13 @@ Invariant:
|
|||||||
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
|
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
|
||||||
- `priority` enum: `critical | high | medium | low`
|
- `priority` enum: `critical | high | medium | low`
|
||||||
- `assignee_agent_id` uuid fk `agents.id` null
|
- `assignee_agent_id` uuid fk `agents.id` null
|
||||||
- `assignee_user_id` text null
|
|
||||||
- checkout/execution locks: `checkout_run_id`, `execution_run_id`, `execution_agent_name_key`, `execution_locked_at`
|
|
||||||
- `created_by_agent_id` uuid fk `agents.id` null
|
- `created_by_agent_id` uuid fk `agents.id` null
|
||||||
- `created_by_user_id` uuid fk `users.id` null
|
- `created_by_user_id` uuid fk `users.id` null
|
||||||
- identifier fields: `issue_number`, `identifier`
|
|
||||||
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
|
|
||||||
- `request_depth` int not null default 0
|
- `request_depth` int not null default 0
|
||||||
- `billing_code` text null
|
- `billing_code` text null
|
||||||
- `assignee_adapter_overrides` jsonb null
|
|
||||||
- `execution_policy` jsonb null
|
|
||||||
- `execution_state` jsonb null
|
|
||||||
- execution workspace fields: `execution_workspace_id`, `execution_workspace_preference`, `execution_workspace_settings`
|
|
||||||
- `started_at` timestamptz null
|
- `started_at` timestamptz null
|
||||||
- `completed_at` timestamptz null
|
- `completed_at` timestamptz null
|
||||||
- `cancelled_at` timestamptz null
|
- `cancelled_at` timestamptz null
|
||||||
- `hidden_at` timestamptz null
|
|
||||||
|
|
||||||
Invariants:
|
Invariants:
|
||||||
|
|
||||||
@@ -288,10 +261,10 @@ Invariant: each event must attach to agent and company; rollups are aggregation,
|
|||||||
|
|
||||||
- `id` uuid pk
|
- `id` uuid pk
|
||||||
- `company_id` uuid fk not null
|
- `company_id` uuid fk not null
|
||||||
- `type` enum: `hire_agent | approve_ceo_strategy | budget_override_required | request_board_approval`
|
- `type` enum: `hire_agent | approve_ceo_strategy`
|
||||||
- `requested_by_agent_id` uuid fk `agents.id` null
|
- `requested_by_agent_id` uuid fk `agents.id` null
|
||||||
- `requested_by_user_id` uuid fk `users.id` null
|
- `requested_by_user_id` uuid fk `users.id` null
|
||||||
- `status` enum: `pending | revision_requested | approved | rejected | cancelled`
|
- `status` enum: `pending | approved | rejected | cancelled`
|
||||||
- `payload` jsonb not null
|
- `payload` jsonb not null
|
||||||
- `decision_note` text null
|
- `decision_note` text null
|
||||||
- `decided_by_user_id` uuid fk `users.id` null
|
- `decided_by_user_id` uuid fk `users.id` null
|
||||||
@@ -390,15 +363,6 @@ Operational policy:
|
|||||||
- `document_id` uuid fk not null
|
- `document_id` uuid fk not null
|
||||||
- `key` text not null (`plan`, `design`, `notes`, etc.)
|
- `key` text not null (`plan`, `design`, `notes`, etc.)
|
||||||
|
|
||||||
## 7.16 Current Implementation Addenda
|
|
||||||
|
|
||||||
The current implementation includes additional V1-control-plane tables beyond the original February snapshot:
|
|
||||||
|
|
||||||
- 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`.
|
|
||||||
- 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
|
## 8. State Machines
|
||||||
|
|
||||||
## 8.1 Agent Status
|
## 8.1 Agent Status
|
||||||
@@ -431,16 +395,6 @@ Side effects:
|
|||||||
- entering `done` sets `completed_at`
|
- entering `done` sets `completed_at`
|
||||||
- entering `cancelled` sets `cancelled_at`
|
- entering `cancelled` sets `cancelled_at`
|
||||||
|
|
||||||
V1 non-terminal liveness rule:
|
|
||||||
|
|
||||||
- agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path
|
|
||||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery action owns the next action
|
|
||||||
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
|
|
||||||
- when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
|
|
||||||
- explicit recovery actions are the liveness primitive; source-scoped actions are the default form, issue-backed recovery is a fallback for independent repair work or safety boundaries, and comments alone are evidence rather than a healthy liveness path
|
|
||||||
|
|
||||||
Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and non-terminal liveness semantics are documented in `doc/execution-semantics.md`.
|
|
||||||
|
|
||||||
## 8.3 Approval Status
|
## 8.3 Approval Status
|
||||||
|
|
||||||
- `pending -> approved | rejected | cancelled`
|
- `pending -> approved | rejected | cancelled`
|
||||||
@@ -528,7 +482,6 @@ All endpoints are under `/api` and return JSON.
|
|||||||
- `DELETE /issues/:issueId/documents/:key`
|
- `DELETE /issues/:issueId/documents/:key`
|
||||||
- `POST /issues/:issueId/checkout`
|
- `POST /issues/:issueId/checkout`
|
||||||
- `POST /issues/:issueId/release`
|
- `POST /issues/:issueId/release`
|
||||||
- `POST /issues/:issueId/admin/force-release` (board-only lock recovery)
|
|
||||||
- `POST /issues/:issueId/comments`
|
- `POST /issues/:issueId/comments`
|
||||||
- `GET /issues/:issueId/comments`
|
- `GET /issues/:issueId/comments`
|
||||||
- `POST /companies/:companyId/issues/:issueId/attachments` (multipart upload)
|
- `POST /companies/:companyId/issues/:issueId/attachments` (multipart upload)
|
||||||
@@ -553,8 +506,6 @@ Server behavior:
|
|||||||
2. if updated row count is 0, return `409` with current owner/status
|
2. if updated row count is 0, return `409` with current owner/status
|
||||||
3. successful checkout sets `assignee_agent_id`, `status = in_progress`, and `started_at`
|
3. successful checkout sets `assignee_agent_id`, `status = in_progress`, and `started_at`
|
||||||
|
|
||||||
`POST /issues/:issueId/admin/force-release` is an operator recovery endpoint for stale harness locks. It requires board access to the issue company, clears checkout and execution run lock fields, and may clear the agent assignee when `clearAssignee=true` is passed. The route must write an `issue.admin_force_release` activity log entry containing the previous checkout and execution run IDs.
|
|
||||||
|
|
||||||
## 10.5 Projects
|
## 10.5 Projects
|
||||||
|
|
||||||
- `GET /companies/:companyId/projects`
|
- `GET /companies/:companyId/projects`
|
||||||
@@ -600,17 +551,6 @@ Dashboard payload must include:
|
|||||||
- `422` semantic rule violation
|
- `422` semantic rule violation
|
||||||
- `500` server error
|
- `500` server error
|
||||||
|
|
||||||
## 10.10 Current Implementation API Addenda
|
|
||||||
|
|
||||||
The current app also exposes V1-supporting surfaces for:
|
|
||||||
|
|
||||||
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`)
|
|
||||||
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
|
|
||||||
- execution workspaces, project workspaces, workspace runtime services, and workspace operations
|
|
||||||
- routines and scheduled/API/webhook triggers
|
|
||||||
- plugin installation, configuration, state, jobs, logs, webhooks, and plugin database namespace migration
|
|
||||||
- company import/export preview/apply, feedback export/vote routes, instance backup/config routes, invites, join requests, memberships, and permission grants
|
|
||||||
|
|
||||||
## 11. Heartbeat and Adapter Contract
|
## 11. Heartbeat and Adapter Contract
|
||||||
|
|
||||||
## 11.1 Adapter Interface
|
## 11.1 Adapter Interface
|
||||||
@@ -677,7 +617,7 @@ Per-agent schedule fields in `adapter_config`:
|
|||||||
|
|
||||||
- `enabled` boolean
|
- `enabled` boolean
|
||||||
- `intervalSec` integer (minimum 30)
|
- `intervalSec` integer (minimum 30)
|
||||||
- `maxConcurrentRuns` integer; new agents default to `20`; scheduler clamps configured values to `1..50`
|
- `maxConcurrentRuns` fixed at `1` for V1
|
||||||
|
|
||||||
Scheduler must skip invocation when:
|
Scheduler must skip invocation when:
|
||||||
|
|
||||||
@@ -786,14 +726,13 @@ Required UX behaviors:
|
|||||||
|
|
||||||
- Node 20+
|
- Node 20+
|
||||||
- `DATABASE_URL` optional
|
- `DATABASE_URL` optional
|
||||||
- if unset, auto-use embedded PostgreSQL under `~/.paperclip/instances/default/db`
|
- if unset, auto-use PGlite and push schema
|
||||||
|
|
||||||
## 15.2 Migrations
|
## 15.2 Migrations
|
||||||
|
|
||||||
- Drizzle migrations are source of truth
|
- Drizzle migrations are source of truth
|
||||||
- local/dev startup applies pending migrations automatically where supported
|
|
||||||
- `pnpm db:migrate` applies pending migrations manually
|
|
||||||
- no destructive migration in-place for V1 upgrade path
|
- no destructive migration in-place for V1 upgrade path
|
||||||
|
- provide migration script from existing minimal tables to company-scoped schema
|
||||||
|
|
||||||
## 15.3 Logging and Audit
|
## 15.3 Logging and Audit
|
||||||
|
|
||||||
@@ -848,8 +787,6 @@ A release candidate is blocked unless these pass:
|
|||||||
|
|
||||||
## 18. Delivery Plan
|
## 18. Delivery Plan
|
||||||
|
|
||||||
Current implementation note: the milestones below describe the original V1 sequencing. Several systems originally framed as future work have since shipped or advanced materially, including issue documents/interactions, blockers, routines, execution workspaces, import/export portability, authenticated deployment modes, multi-user basics, and the local/self-hosted plugin runtime.
|
|
||||||
|
|
||||||
## Milestone 1: Company Core and Auth
|
## Milestone 1: Company Core and Auth
|
||||||
|
|
||||||
- add `companies` and company scoping to existing entities
|
- add `companies` and company scoping to existing entities
|
||||||
@@ -902,7 +839,7 @@ V1 is complete only when all criteria are true:
|
|||||||
|
|
||||||
## 20. Post-V1 Backlog (Explicitly Deferred)
|
## 20. Post-V1 Backlog (Explicitly Deferred)
|
||||||
|
|
||||||
- cloud-grade plugin marketplace/distribution
|
- plugin architecture
|
||||||
- richer workflow-state customization per team
|
- richer workflow-state customization per team
|
||||||
- milestones/labels/dependency graph depth beyond V1 minimum
|
- milestones/labels/dependency graph depth beyond V1 minimum
|
||||||
- realtime transport optimization (SSE/WebSockets)
|
- realtime transport optimization (SSE/WebSockets)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,433 +0,0 @@
|
|||||||
# Execution Semantics
|
|
||||||
|
|
||||||
Status: Current implementation guide
|
|
||||||
Date: 2026-04-26
|
|
||||||
Audience: Product and engineering
|
|
||||||
|
|
||||||
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
|
|
||||||
|
|
||||||
`doc/SPEC-implementation.md` remains the V1 contract. This document is the detailed execution model behind that contract.
|
|
||||||
|
|
||||||
## 1. Core Model
|
|
||||||
|
|
||||||
Paperclip separates four concepts that are easy to blur together:
|
|
||||||
|
|
||||||
1. structure: parent/sub-issue relationships
|
|
||||||
2. dependency: blocker relationships
|
|
||||||
3. ownership: who is responsible for the issue now
|
|
||||||
4. execution: whether the control plane currently has a live path to move the issue forward
|
|
||||||
|
|
||||||
The system works best when those are kept separate.
|
|
||||||
|
|
||||||
## 2. Assignee Semantics
|
|
||||||
|
|
||||||
An issue has at most one assignee.
|
|
||||||
|
|
||||||
- `assigneeAgentId` means the issue is owned by an agent
|
|
||||||
- `assigneeUserId` means the issue is owned by a human board user
|
|
||||||
- both cannot be set at the same time
|
|
||||||
|
|
||||||
This is a hard invariant. Paperclip is single-assignee by design.
|
|
||||||
|
|
||||||
## 3. Status Semantics
|
|
||||||
|
|
||||||
Paperclip issue statuses are not just UI labels. They imply different expectations about ownership and execution.
|
|
||||||
|
|
||||||
### `backlog`
|
|
||||||
|
|
||||||
The issue is not ready for active work.
|
|
||||||
|
|
||||||
- no execution expectation
|
|
||||||
- no pickup expectation
|
|
||||||
- safe resting state for future work
|
|
||||||
|
|
||||||
### `todo`
|
|
||||||
|
|
||||||
The issue is actionable but not actively claimed.
|
|
||||||
|
|
||||||
- it may be assigned or unassigned
|
|
||||||
- no checkout/execution lock is required yet
|
|
||||||
- for agent-assigned work, Paperclip may still need a wake path to ensure the assignee actually sees it
|
|
||||||
|
|
||||||
### `in_progress`
|
|
||||||
|
|
||||||
The issue is actively owned work.
|
|
||||||
|
|
||||||
- requires an assignee
|
|
||||||
- for agent-owned issues, this is a strict execution-backed state
|
|
||||||
- for user-owned issues, this is a human ownership state and is not backed by heartbeat execution
|
|
||||||
|
|
||||||
For agent-owned issues, `in_progress` should not be allowed to become a silent dead state.
|
|
||||||
|
|
||||||
### `blocked`
|
|
||||||
|
|
||||||
The issue cannot proceed until something external changes.
|
|
||||||
|
|
||||||
This is the right state for:
|
|
||||||
|
|
||||||
- waiting on another issue
|
|
||||||
- waiting on a human decision
|
|
||||||
- waiting on an external dependency or system when Paperclip does not own a scheduled re-check
|
|
||||||
- work that automatic recovery could not safely continue
|
|
||||||
|
|
||||||
### `in_review`
|
|
||||||
|
|
||||||
Execution work is paused because the next move belongs to a reviewer or approver, not the current executor.
|
|
||||||
|
|
||||||
An external review service can also be a valid review path when the issue keeps an agent assignee and has an active one-shot monitor that will wake that assignee to check the service later.
|
|
||||||
|
|
||||||
### `done`
|
|
||||||
|
|
||||||
The work is complete and terminal.
|
|
||||||
|
|
||||||
### `cancelled`
|
|
||||||
|
|
||||||
The work will not continue and is terminal.
|
|
||||||
|
|
||||||
## 4. Agent-Owned vs User-Owned Execution
|
|
||||||
|
|
||||||
The execution model differs depending on assignee type.
|
|
||||||
|
|
||||||
### Agent-owned issues
|
|
||||||
|
|
||||||
Agent-owned issues are part of the control plane's execution loop.
|
|
||||||
|
|
||||||
- Paperclip can wake the assignee
|
|
||||||
- Paperclip can track runs linked to the issue
|
|
||||||
- Paperclip can recover some lost execution state after crashes/restarts
|
|
||||||
|
|
||||||
### User-owned issues
|
|
||||||
|
|
||||||
User-owned issues are not executed by the heartbeat scheduler.
|
|
||||||
|
|
||||||
- Paperclip can track the ownership and status
|
|
||||||
- Paperclip cannot rely on heartbeat/run semantics to keep them moving
|
|
||||||
- stranded-work reconciliation does not apply to them
|
|
||||||
|
|
||||||
This is why `in_progress` can be strict for agents without forcing the same runtime rules onto human-held work.
|
|
||||||
|
|
||||||
## 5. Checkout and Active Execution
|
|
||||||
|
|
||||||
Checkout is the bridge from issue ownership to active agent execution.
|
|
||||||
|
|
||||||
- checkout is required to move an issue into agent-owned `in_progress`
|
|
||||||
- `checkoutRunId` represents issue-ownership lock for the current agent run
|
|
||||||
- `executionRunId` represents the currently active execution path for the issue
|
|
||||||
|
|
||||||
These are related but not identical:
|
|
||||||
|
|
||||||
- `checkoutRunId` answers who currently owns execution rights for the issue
|
|
||||||
- `executionRunId` answers which run is actually live right now
|
|
||||||
|
|
||||||
Paperclip already clears stale execution locks and can adopt some stale checkout locks when the original run is gone.
|
|
||||||
|
|
||||||
## 6. Parent/Sub-Issue vs Blockers
|
|
||||||
|
|
||||||
Paperclip uses two different relationships for different jobs.
|
|
||||||
|
|
||||||
### Parent/Sub-Issue (`parentId`)
|
|
||||||
|
|
||||||
This is structural.
|
|
||||||
|
|
||||||
Use it for:
|
|
||||||
|
|
||||||
- work breakdown
|
|
||||||
- rollup context
|
|
||||||
- explaining why a child issue exists
|
|
||||||
- waking the parent assignee when all direct children become terminal
|
|
||||||
|
|
||||||
Do not treat `parentId` as execution dependency by itself.
|
|
||||||
|
|
||||||
### Blockers (`blockedByIssueIds`)
|
|
||||||
|
|
||||||
This is dependency semantics.
|
|
||||||
|
|
||||||
Use it for:
|
|
||||||
|
|
||||||
- \"this issue cannot continue until that issue changes state\"
|
|
||||||
- explicit waiting relationships
|
|
||||||
- automatic wakeups when all blockers resolve
|
|
||||||
|
|
||||||
Blocked issues should stay idle while blockers remain unresolved. Paperclip should not create a queued heartbeat run for that issue until the final blocker is done and the `issue_blockers_resolved` wake can start real work.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
|
|
||||||
|
|
||||||
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible notice, or an explicit recovery action. It must not silently mark work done from prose comments or guess that a dependency is complete.
|
|
||||||
|
|
||||||
An issue is healthy when the product can answer "what moves this forward next?" without requiring a human to reconstruct intent from the whole thread. An issue is stalled when it is non-terminal but has no live execution path, no explicit waiting path, and no recovery path.
|
|
||||||
|
|
||||||
The valid action-path primitives are:
|
|
||||||
|
|
||||||
- an active run linked to the issue
|
|
||||||
- a queued wake or continuation that can be delivered to the responsible agent
|
|
||||||
- a typed execution-policy participant, such as `executionState.currentParticipant`
|
|
||||||
- a pending issue-thread interaction or linked approval that is waiting for a specific responder
|
|
||||||
- a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check
|
|
||||||
- a human owner via `assigneeUserId`
|
|
||||||
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
|
|
||||||
- an open explicit recovery action that names the owner and action needed to restore liveness
|
|
||||||
|
|
||||||
### Explicit recovery actions
|
|
||||||
|
|
||||||
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
|
|
||||||
|
|
||||||
A valid recovery action must name:
|
|
||||||
|
|
||||||
- the source issue and company
|
|
||||||
- the recovery kind and idempotency fingerprint
|
|
||||||
- the recovery owner, plus previous or return owner when ownership may temporarily shift
|
|
||||||
- the cause, bounded evidence, and next action
|
|
||||||
- the wake, monitor, timeout, retry, or escalation policy that will move the action forward
|
|
||||||
- the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled
|
|
||||||
|
|
||||||
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
|
|
||||||
|
|
||||||
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
|
|
||||||
|
|
||||||
- long or cross-agent repair work with its own assignee, subtasks, or blockers
|
|
||||||
- real delegated follow-up that should block the source issue as a first-class dependency
|
|
||||||
- active-run watchdog work that must observe a still-running source process without interfering with it
|
|
||||||
- recovery that needs separate review, approval, security handling, or escalation ownership
|
|
||||||
- cases where source issue ownership cannot be changed or restored safely
|
|
||||||
|
|
||||||
A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome.
|
|
||||||
|
|
||||||
### Agent-assigned `todo`
|
|
||||||
|
|
||||||
This is dispatch state: ready to start, not yet actively claimed.
|
|
||||||
|
|
||||||
A healthy dispatch state means at least one of these is true:
|
|
||||||
|
|
||||||
- the issue already has a queued wake path
|
|
||||||
- the issue is intentionally resting in `todo` after a completed agent heartbeat, with no interrupted dispatch evidence
|
|
||||||
- the issue has been explicitly surfaced as stranded through a visible blocked/recovery path
|
|
||||||
|
|
||||||
An assigned `todo` issue is stalled when dispatch was interrupted, no wake remains queued or running, and no recovery path has been opened.
|
|
||||||
|
|
||||||
### Agent-assigned `backlog`
|
|
||||||
|
|
||||||
This is parked state, not dispatch state.
|
|
||||||
|
|
||||||
Assigning an issue normally implies executable intent. When create APIs receive an assignee and no explicit status, Paperclip defaults the issue to `todo` so the assignee has a wake path instead of silently inheriting the unassigned `backlog` default.
|
|
||||||
|
|
||||||
An explicit assigned `backlog` issue remains valid when the creator is deliberately parking the work. It must not wake the assignee just because it has an assignee. Paperclip should make that choice visible in activity and UI so operators can distinguish intentional parking from a missed handoff.
|
|
||||||
|
|
||||||
An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery action. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy.
|
|
||||||
|
|
||||||
### Agent-assigned `in_progress`
|
|
||||||
|
|
||||||
This is active-work state.
|
|
||||||
|
|
||||||
A healthy active-work state means at least one of these is true:
|
|
||||||
|
|
||||||
- there is an active run for the issue
|
|
||||||
- there is already a queued continuation wake
|
|
||||||
- there is an active one-shot monitor that will wake the assignee for a future check
|
|
||||||
- there is an open explicit recovery action for the lost execution path
|
|
||||||
|
|
||||||
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
|
|
||||||
|
|
||||||
### `in_review`
|
|
||||||
|
|
||||||
This is review/approval state: execution is paused because the next move belongs to a reviewer, approver, board user, or recovery owner.
|
|
||||||
|
|
||||||
A healthy `in_review` issue has at least one valid action path:
|
|
||||||
|
|
||||||
- a typed execution-policy participant who can approve or request changes
|
|
||||||
- a pending issue-thread interaction or linked approval waiting for a named responder
|
|
||||||
- a human owner via `assigneeUserId`
|
|
||||||
- an active run or queued wake that is expected to process the review state
|
|
||||||
- an active one-shot monitor for an external service or async review loop that the assignee owns
|
|
||||||
- an open explicit recovery action for an ambiguous review handoff
|
|
||||||
|
|
||||||
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
|
|
||||||
|
|
||||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
|
||||||
|
|
||||||
### Issue monitors
|
|
||||||
|
|
||||||
An issue monitor is a one-shot deferred action path for agent-owned issues in `in_progress` or `in_review`.
|
|
||||||
|
|
||||||
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
|
|
||||||
|
|
||||||
Monitor policy lives under `executionPolicy.monitor` and includes:
|
|
||||||
|
|
||||||
- `nextCheckAt`: when Paperclip should wake the assignee
|
|
||||||
- `notes`: non-secret instructions for what the assignee should check
|
|
||||||
- `serviceName`: optional non-secret external-service context
|
|
||||||
- `externalRef`: optional external-service reference input; Paperclip treats it as secret-adjacent, redacts it before persistence/visibility, and omits it from activity and wake payloads
|
|
||||||
- `timeoutAt`, `maxAttempts`, and `recoveryPolicy`: optional recovery hints for bounded waits
|
|
||||||
|
|
||||||
Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared.
|
|
||||||
|
|
||||||
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
|
|
||||||
|
|
||||||
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
|
|
||||||
|
|
||||||
Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work.
|
|
||||||
|
|
||||||
### `blocked`
|
|
||||||
|
|
||||||
This is explicit waiting state.
|
|
||||||
|
|
||||||
A healthy `blocked` issue has an explicit waiting path:
|
|
||||||
|
|
||||||
- first-class blockers exist, and each unresolved leaf has a valid action path under this contract
|
|
||||||
- the issue has an explicit recovery action that itself has a live or waiting path
|
|
||||||
- the issue is waiting on a pending interaction, linked approval, human owner, or clearly named external owner/action
|
|
||||||
|
|
||||||
A blocker chain is covered only when its unresolved leaf is live or explicitly waiting. An intermediate `blocked` issue does not make the chain healthy by itself.
|
|
||||||
|
|
||||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
|
||||||
|
|
||||||
## 8. Crash and Restart Recovery
|
|
||||||
|
|
||||||
Paperclip now treats crash/restart recovery as a stranded-assigned-work problem, not just a stranded-run problem.
|
|
||||||
|
|
||||||
There are two distinct failure modes.
|
|
||||||
|
|
||||||
### 8.1 Stranded assigned `todo`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
- issue is assigned to an agent
|
|
||||||
- status is `todo`
|
|
||||||
- the original wake/run died during or after dispatch
|
|
||||||
- after restart there is no queued wake and nothing picks the issue back up
|
|
||||||
|
|
||||||
Recovery rule:
|
|
||||||
|
|
||||||
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery wake
|
|
||||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
|
||||||
|
|
||||||
This is a dispatch recovery, not a continuation recovery.
|
|
||||||
|
|
||||||
### 8.2 Stranded assigned `in_progress`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
- issue is assigned to an agent
|
|
||||||
- status is `in_progress`
|
|
||||||
- the live run disappeared
|
|
||||||
- after restart there is no active run and no queued continuation
|
|
||||||
|
|
||||||
Recovery rule:
|
|
||||||
|
|
||||||
- Paperclip queues one automatic continuation wake
|
|
||||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
|
||||||
|
|
||||||
This is an active-work continuity recovery.
|
|
||||||
|
|
||||||
## 9. Startup and Periodic Reconciliation
|
|
||||||
|
|
||||||
Startup recovery and periodic recovery are different from normal wakeup delivery.
|
|
||||||
|
|
||||||
On startup and on the periodic recovery loop, Paperclip now does four 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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 10. Silent Active-Run Watchdog
|
|
||||||
|
|
||||||
An active run can still be unhealthy even when its process is `running`. Paperclip treats prolonged output silence as a watchdog signal, not as proof that the run is failed.
|
|
||||||
|
|
||||||
The recovery service owns this contract:
|
|
||||||
|
|
||||||
- classify active-run output silence as `ok`, `suspicious`, `critical`, `snoozed`, or `not_applicable`
|
|
||||||
- collect bounded evidence from run logs, recent run events, child issues, and blockers
|
|
||||||
- preserve redaction and truncation before evidence is written to issue descriptions
|
|
||||||
- create at most one open watchdog recovery action per run; issue-backed implementations use `stale_active_run_evaluation` issues
|
|
||||||
- honor active snooze decisions before creating more review work
|
|
||||||
- build the `outputSilence` summary shown by live-run and active-run API responses
|
|
||||||
|
|
||||||
Suspicious silence creates a medium-priority watchdog recovery action for the selected recovery owner. Critical silence raises that recovery action to high priority and, when issue-backed evaluation is needed for correctness, blocks the source issue on the explicit evaluation task without cancelling the active process.
|
|
||||||
|
|
||||||
Watchdog decisions are explicit operator/recovery-owner decisions:
|
|
||||||
|
|
||||||
- `snooze` records an operator-chosen future quiet-until time and suppresses scan-created review work during that window
|
|
||||||
- `continue` records that the current evidence is acceptable, does not cancel or mutate the active run, and sets a 30-minute default re-arm window before the watchdog evaluates the still-silent run again
|
|
||||||
- `dismissed_false_positive` records why the review was not actionable
|
|
||||||
|
|
||||||
Operators should prefer `snooze` for known time-bounded quiet periods. `continue` is only a short acknowledgement of the current evidence; if the run remains silent after the re-arm window, the periodic watchdog scan can create or update review work again.
|
|
||||||
|
|
||||||
The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
|
|
||||||
|
|
||||||
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
|
|
||||||
|
|
||||||
Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
|
|
||||||
|
|
||||||
### Auto-Recover
|
|
||||||
|
|
||||||
Auto-recovery is allowed when ownership is clear and the control plane only lost execution continuity.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- requeue one dispatch wake for an assigned `todo` issue whose latest run failed, timed out, or was cancelled
|
|
||||||
- requeue one continuation wake for an assigned `in_progress` issue whose live execution path disappeared
|
|
||||||
- assign an orphan blocker back to its creator when that blocker is already preventing other work
|
|
||||||
|
|
||||||
Auto-recovery preserves the existing owner. It does not choose a replacement agent.
|
|
||||||
|
|
||||||
### Explicit Recovery Action
|
|
||||||
|
|
||||||
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- automatic stranded-work retry was already exhausted
|
|
||||||
- a dependency graph has an invalid/uninvokable owner, unassigned blocker, or invalid review participant
|
|
||||||
- an active run is silent past the watchdog threshold
|
|
||||||
|
|
||||||
The recovery action stays source-scoped by default. The source issue should show the recovery owner, cause, evidence, next action, and wake or monitor policy in its own thread/detail surface.
|
|
||||||
|
|
||||||
Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive.
|
|
||||||
|
|
||||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
|
||||||
|
|
||||||
### Human Escalation
|
|
||||||
|
|
||||||
Human escalation is required when the next safe action depends on board judgment, budget/approval policy, or information unavailable to the control plane.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- all candidate recovery owners are paused, terminated, pending approval, or budget-blocked
|
|
||||||
- the issue is human-owned rather than agent-owned
|
|
||||||
- the run is intentionally quiet but needs an operator decision before cancellation or continuation
|
|
||||||
|
|
||||||
In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying.
|
|
||||||
|
|
||||||
## 12. What This Does Not Mean
|
|
||||||
|
|
||||||
These semantics do not change V1 into an auto-reassignment system.
|
|
||||||
|
|
||||||
Paperclip still does not:
|
|
||||||
|
|
||||||
- automatically reassign work to a different agent
|
|
||||||
- infer dependency semantics from `parentId` alone
|
|
||||||
- treat human-held work as heartbeat-managed execution
|
|
||||||
|
|
||||||
The recovery model is intentionally conservative:
|
|
||||||
|
|
||||||
- preserve ownership
|
|
||||||
- retry once when the control plane lost execution continuity
|
|
||||||
- open an explicit recovery action when the system can identify a bounded recovery owner/action
|
|
||||||
- escalate visibly when the system cannot safely keep going
|
|
||||||
|
|
||||||
## 13. Practical Interpretation
|
|
||||||
|
|
||||||
For a board operator, the intended meaning is:
|
|
||||||
|
|
||||||
- agent-owned `in_progress` should mean \"this is live work or clearly surfaced as a problem\"
|
|
||||||
- agent-owned `todo` should not stay assigned forever after a crash with no remaining wake path
|
|
||||||
- parent/sub-issue explains structure
|
|
||||||
- blockers explain waiting
|
|
||||||
|
|
||||||
That is the execution contract Paperclip should present to operators.
|
|
||||||
@@ -22,7 +22,6 @@ The question is not "which memory project wins?" The question is "what is the sm
|
|||||||
### Hosted memory APIs
|
### Hosted memory APIs
|
||||||
|
|
||||||
- `mem0`
|
- `mem0`
|
||||||
- `AWS Bedrock AgentCore Memory`
|
|
||||||
- `supermemory`
|
- `supermemory`
|
||||||
- `Memori`
|
- `Memori`
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ These emphasize local persistence, inspectability, and low operational overhead.
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service |
|
| [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service |
|
||||||
| [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Paperclip should not assume every backend behaves like mem0 |
|
| [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Paperclip should not assume every backend behaves like mem0 |
|
||||||
| [AWS Bedrock AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html) | AWS-managed memory service | explicit short-term and long-term memories, actor/session/event APIs, memory strategies, namespace templates, optional self-managed extraction pipeline | strong example of provider-managed memory with clear scoped ids, retention controls, and standalone API access outside a single agent framework | AWS-hosted and IAM-centric; Paperclip would still need its own company/run/comment provenance, cost rollups, and likely a plugin wrapper instead of baking AWS semantics into core |
|
|
||||||
| [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Paperclip should standardize first |
|
| [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Paperclip should standardize first |
|
||||||
| [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow |
|
| [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow |
|
||||||
| [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Paperclip's task-centric control plane |
|
| [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Paperclip's task-centric control plane |
|
||||||
@@ -79,7 +77,6 @@ These differences are exactly why Paperclip needs a layered contract instead of
|
|||||||
### 1. Who owns extraction?
|
### 1. Who owns extraction?
|
||||||
|
|
||||||
- `mem0`, `supermemory`, and `Memori` expect the provider to infer memories from conversations.
|
- `mem0`, `supermemory`, and `Memori` expect the provider to infer memories from conversations.
|
||||||
- `AWS Bedrock AgentCore Memory` supports both provider-managed extraction and self-managed pipelines where the host writes curated long-term memory records.
|
|
||||||
- `memsearch` expects the host to decide what markdown to write, then indexes it.
|
- `memsearch` expects the host to decide what markdown to write, then indexes it.
|
||||||
- `MemOS`, `memU`, `EverMemOS`, and `OpenViking` sit somewhere in between and often expose richer memory construction pipelines.
|
- `MemOS`, `memU`, `EverMemOS`, and `OpenViking` sit somewhere in between and often expose richer memory construction pipelines.
|
||||||
|
|
||||||
@@ -107,7 +104,6 @@ Paperclip should make plain search the minimum contract and richer outputs optio
|
|||||||
### 4. Is memory synchronous or asynchronous?
|
### 4. Is memory synchronous or asynchronous?
|
||||||
|
|
||||||
- local tools often work synchronously in-process.
|
- local tools often work synchronously in-process.
|
||||||
- `AWS Bedrock AgentCore Memory` is synchronous at the API edge, but its long-term memory path includes background extraction/indexing behavior and retention policies managed by the provider.
|
|
||||||
- larger systems add schedulers, background indexing, compaction, or sync jobs.
|
- larger systems add schedulers, background indexing, compaction, or sync jobs.
|
||||||
|
|
||||||
Paperclip needs both direct request/response operations and background maintenance hooks.
|
Paperclip needs both direct request/response operations and background maintenance hooks.
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ Define a Paperclip memory service and surface API that can sit above multiple me
|
|||||||
- company scoping
|
- company scoping
|
||||||
- auditability
|
- auditability
|
||||||
- provenance back to Paperclip work objects
|
- provenance back to Paperclip work objects
|
||||||
- budget and cost visibility
|
- budget / cost visibility
|
||||||
- plugin-first extensibility
|
- plugin-first extensibility
|
||||||
|
|
||||||
This plan is based on the external landscape summarized in `doc/memory-landscape.md`, the AWS AgentCore comparison captured in [PAP-1274](/PAP/issues/PAP-1274), and the current Paperclip architecture in:
|
This plan is based on the external landscape summarized in `doc/memory-landscape.md` and on the current Paperclip architecture in:
|
||||||
|
|
||||||
- `doc/SPEC-implementation.md`
|
- `doc/SPEC-implementation.md`
|
||||||
- `doc/plugins/PLUGIN_SPEC.md`
|
- `doc/plugins/PLUGIN_SPEC.md`
|
||||||
@@ -19,26 +19,23 @@ This plan is based on the external landscape summarized in `doc/memory-landscape
|
|||||||
|
|
||||||
## Recommendation In One Sentence
|
## Recommendation In One Sentence
|
||||||
|
|
||||||
Paperclip should add a company-scoped memory control plane with company default plus agent override resolution, shared hook delivery, and full operation attribution, while leaving extraction and storage semantics to built-ins and plugins.
|
Paperclip should not embed one opinionated memory engine into core. It should add a company-scoped memory control plane with a small normalized adapter contract, then let built-ins and plugins implement the provider-specific behavior.
|
||||||
|
|
||||||
## Product Decisions
|
## Product Decisions
|
||||||
|
|
||||||
### 1. Memory resolution is company default plus agent override
|
### 1. Memory is company-scoped by default
|
||||||
|
|
||||||
Every memory binding belongs to exactly one company.
|
Every memory binding belongs to exactly one company.
|
||||||
|
|
||||||
Resolution order in V1:
|
That binding can then be:
|
||||||
|
|
||||||
- company default binding
|
- the company default
|
||||||
- optional per-agent override
|
- an agent override
|
||||||
|
- a project override later if we need it
|
||||||
There is no per-project override in V1.
|
|
||||||
|
|
||||||
Project context can still appear in scope and provenance so providers can use it for retrieval and partitioning, but projects do not participate in binding selection.
|
|
||||||
|
|
||||||
No cross-company memory sharing in the initial design.
|
No cross-company memory sharing in the initial design.
|
||||||
|
|
||||||
### 2. Providers are selected by stable binding key
|
### 2. Providers are selected by key
|
||||||
|
|
||||||
Each configured memory provider gets a stable key inside a company, for example:
|
Each configured memory provider gets a stable key inside a company, for example:
|
||||||
|
|
||||||
@@ -47,53 +44,36 @@ Each configured memory provider gets a stable key inside a company, for example:
|
|||||||
- `local-markdown`
|
- `local-markdown`
|
||||||
- `research-kb`
|
- `research-kb`
|
||||||
|
|
||||||
Agents, tools, and background hooks resolve the active provider by key, not by hard-coded vendor logic.
|
Agents and services resolve the active provider by key, not by hard-coded vendor logic.
|
||||||
|
|
||||||
### 3. Plugins are the primary provider path
|
### 3. Plugins are the primary provider path
|
||||||
|
|
||||||
Built-ins are useful for a zero-config local path, but most providers should arrive through the existing Paperclip plugin runtime.
|
Built-ins are useful for a zero-config local path, but most providers should arrive through the existing Paperclip plugin runtime.
|
||||||
|
|
||||||
That keeps the core small and matches the broader Paperclip direction that specialized knowledge systems live at the edges.
|
That keeps the core small and matches the current direction that optional knowledge-like systems live at the edges.
|
||||||
|
|
||||||
### 4. Paperclip owns routing, provenance, and policy
|
### 4. Paperclip owns routing, provenance, and accounting
|
||||||
|
|
||||||
Providers should not decide how Paperclip entities map to governance.
|
Providers should not decide how Paperclip entities map to governance.
|
||||||
|
|
||||||
Paperclip core should own:
|
Paperclip core should own:
|
||||||
|
|
||||||
- binding resolution
|
|
||||||
- who is allowed to call a memory operation
|
- who is allowed to call a memory operation
|
||||||
- which company, agent, issue, project, run, and subject scope is active
|
- which company / agent / project scope is active
|
||||||
- what source object the operation belongs to
|
- what issue / run / comment / document the operation belongs to
|
||||||
- how usage and costs are attributed
|
- how usage gets recorded
|
||||||
- how operators inspect what happened
|
|
||||||
|
|
||||||
### 5. Paperclip exposes shared hooks, providers own extraction
|
### 5. Automatic memory should be narrow at first
|
||||||
|
|
||||||
Paperclip should emit a common set of memory hooks that built-ins, third-party adapters, and plugins can all use.
|
|
||||||
|
|
||||||
Those hooks should pass structured Paperclip source objects plus normalized metadata. The provider then decides how to extract from those objects.
|
|
||||||
|
|
||||||
Paperclip should not force one extraction pipeline or one canonical "memory text" transform before the provider sees the input.
|
|
||||||
|
|
||||||
### 6. Automatic memory should start narrow, but the hook surface should be general
|
|
||||||
|
|
||||||
Automatic capture is useful, but broad silent capture is dangerous.
|
Automatic capture is useful, but broad silent capture is dangerous.
|
||||||
|
|
||||||
Initial built-in automatic hooks should be:
|
Initial automatic hooks should be:
|
||||||
|
|
||||||
- pre-run hydrate for agent context recall
|
|
||||||
- post-run capture from agent runs
|
- post-run capture from agent runs
|
||||||
- optional issue comment capture
|
- issue comment / document capture when the binding enables it
|
||||||
- optional issue document capture
|
- pre-run recall for agent context hydration
|
||||||
|
|
||||||
The hook registry itself should be general enough that other providers can subscribe to the same events without core changes.
|
Everything else should start explicit.
|
||||||
|
|
||||||
### 7. No approval gate for binding changes in the open-source product
|
|
||||||
|
|
||||||
For the open-source version, changing memory bindings should not require approvals.
|
|
||||||
|
|
||||||
Paperclip should still log those changes in activity and preserve full auditability. Approval-gated memory governance can remain an enterprise or future policy layer.
|
|
||||||
|
|
||||||
## Proposed Concepts
|
## Proposed Concepts
|
||||||
|
|
||||||
@@ -103,7 +83,7 @@ A built-in or plugin-supplied implementation that stores and retrieves memory.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
- local markdown plus semantic index
|
- local markdown + vector index
|
||||||
- mem0 adapter
|
- mem0 adapter
|
||||||
- supermemory adapter
|
- supermemory adapter
|
||||||
- MemOS adapter
|
- MemOS adapter
|
||||||
@@ -114,15 +94,6 @@ A company-scoped configuration record that points to a provider and carries prov
|
|||||||
|
|
||||||
This is the object selected by key.
|
This is the object selected by key.
|
||||||
|
|
||||||
### Memory binding target
|
|
||||||
|
|
||||||
A mapping from a Paperclip target to a binding.
|
|
||||||
|
|
||||||
V1 targets:
|
|
||||||
|
|
||||||
- `company`
|
|
||||||
- `agent`
|
|
||||||
|
|
||||||
### Memory scope
|
### Memory scope
|
||||||
|
|
||||||
The normalized Paperclip scope passed into a provider request.
|
The normalized Paperclip scope passed into a provider request.
|
||||||
@@ -134,9 +105,7 @@ At minimum:
|
|||||||
- optional `projectId`
|
- optional `projectId`
|
||||||
- optional `issueId`
|
- optional `issueId`
|
||||||
- optional `runId`
|
- optional `runId`
|
||||||
- optional `subjectId` for external or user identity
|
- optional `subjectId` for external/user identity
|
||||||
- optional `sessionKey` for providers that organize memory around sessions
|
|
||||||
- optional `namespace` for providers that need an explicit partition hint
|
|
||||||
|
|
||||||
### Memory source reference
|
### Memory source reference
|
||||||
|
|
||||||
@@ -152,36 +121,24 @@ Supported source kinds should include:
|
|||||||
- `manual_note`
|
- `manual_note`
|
||||||
- `external_document`
|
- `external_document`
|
||||||
|
|
||||||
### Memory hook
|
|
||||||
|
|
||||||
A normalized trigger emitted by Paperclip when something memory-relevant happens.
|
|
||||||
|
|
||||||
Initial hook kinds:
|
|
||||||
|
|
||||||
- `pre_run_hydrate`
|
|
||||||
- `post_run_capture`
|
|
||||||
- `issue_comment_capture`
|
|
||||||
- `issue_document_capture`
|
|
||||||
- `manual_capture`
|
|
||||||
|
|
||||||
### Memory operation
|
### Memory operation
|
||||||
|
|
||||||
A normalized capture, record-write, query, browse, get, correction, or delete action performed through Paperclip.
|
A normalized write, query, browse, or delete action performed through Paperclip.
|
||||||
|
|
||||||
Paperclip should log every memory operation whether the provider is local, plugin-backed, or external.
|
Paperclip should log every operation, whether the provider is local or external.
|
||||||
|
|
||||||
## Required Adapter Contract
|
## Required Adapter Contract
|
||||||
|
|
||||||
The required core should be small enough to fit `memsearch`, `mem0`, `Memori`, `MemOS`, or `OpenViking`, but strong enough to satisfy Paperclip's attribution and inspectability requirements.
|
The required core should be small enough to fit `memsearch`, `mem0`, `Memori`, `MemOS`, or `OpenViking`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface MemoryAdapterCapabilities {
|
export interface MemoryAdapterCapabilities {
|
||||||
profile?: boolean;
|
profile?: boolean;
|
||||||
|
browse?: boolean;
|
||||||
correction?: boolean;
|
correction?: boolean;
|
||||||
|
asyncIngestion?: boolean;
|
||||||
multimodal?: boolean;
|
multimodal?: boolean;
|
||||||
providerManagedExtraction?: boolean;
|
providerManagedExtraction?: boolean;
|
||||||
asyncExtraction?: boolean;
|
|
||||||
providerNativeBrowse?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryScope {
|
export interface MemoryScope {
|
||||||
@@ -191,8 +148,6 @@ export interface MemoryScope {
|
|||||||
issueId?: string;
|
issueId?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
subjectId?: string;
|
subjectId?: string;
|
||||||
sessionKey?: string;
|
|
||||||
namespace?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemorySourceRef {
|
export interface MemorySourceRef {
|
||||||
@@ -213,34 +168,10 @@ export interface MemorySourceRef {
|
|||||||
externalRef?: string;
|
externalRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryHookContext {
|
|
||||||
hookKind:
|
|
||||||
| "pre_run_hydrate"
|
|
||||||
| "post_run_capture"
|
|
||||||
| "issue_comment_capture"
|
|
||||||
| "issue_document_capture"
|
|
||||||
| "manual_capture";
|
|
||||||
hookId: string;
|
|
||||||
triggeredAt: string;
|
|
||||||
actorAgentId?: string;
|
|
||||||
heartbeatRunId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemorySourcePayload {
|
|
||||||
text?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
object?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryUsage {
|
export interface MemoryUsage {
|
||||||
provider: string;
|
provider: string;
|
||||||
biller?: string;
|
|
||||||
model?: string;
|
model?: string;
|
||||||
billingType?: "metered_api" | "subscription_included" | "subscription_overage" | "unknown";
|
|
||||||
attributionMode?: "billed_directly" | "included_in_run" | "external_invoice" | "untracked";
|
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
cachedInputTokens?: number;
|
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
embeddingTokens?: number;
|
embeddingTokens?: number;
|
||||||
costCents?: number;
|
costCents?: number;
|
||||||
@@ -248,30 +179,18 @@ export interface MemoryUsage {
|
|||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryRecordHandle {
|
export interface MemoryWriteRequest {
|
||||||
providerKey: string;
|
|
||||||
providerRecordId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryCaptureRequest {
|
|
||||||
bindingKey: string;
|
bindingKey: string;
|
||||||
scope: MemoryScope;
|
scope: MemoryScope;
|
||||||
source: MemorySourceRef;
|
source: MemorySourceRef;
|
||||||
payload: MemorySourcePayload;
|
content: string;
|
||||||
hook?: MemoryHookContext;
|
|
||||||
mode?: "capture_residue" | "capture_record";
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
|
mode?: "append" | "upsert" | "summarize";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryRecordWriteRequest {
|
export interface MemoryRecordHandle {
|
||||||
bindingKey: string;
|
providerKey: string;
|
||||||
scope: MemoryScope;
|
providerRecordId: string;
|
||||||
source?: MemorySourceRef;
|
|
||||||
records: Array<{
|
|
||||||
text: string;
|
|
||||||
summary?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryQueryRequest {
|
export interface MemoryQueryRequest {
|
||||||
@@ -283,14 +202,6 @@ export interface MemoryQueryRequest {
|
|||||||
metadataFilter?: Record<string, unknown>;
|
metadataFilter?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryListRequest {
|
|
||||||
bindingKey: string;
|
|
||||||
scope: MemoryScope;
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
metadataFilter?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemorySnippet {
|
export interface MemorySnippet {
|
||||||
handle: MemoryRecordHandle;
|
handle: MemoryRecordHandle;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -306,149 +217,30 @@ export interface MemoryContextBundle {
|
|||||||
usage?: MemoryUsage[];
|
usage?: MemoryUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryListPage {
|
|
||||||
items: MemorySnippet[];
|
|
||||||
nextCursor?: string;
|
|
||||||
usage?: MemoryUsage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryExtractionJob {
|
|
||||||
providerJobId: string;
|
|
||||||
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
|
||||||
hookKind?: MemoryHookContext["hookKind"];
|
|
||||||
source?: MemorySourceRef;
|
|
||||||
error?: string;
|
|
||||||
submittedAt?: string;
|
|
||||||
startedAt?: string;
|
|
||||||
finishedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryAdapter {
|
export interface MemoryAdapter {
|
||||||
key: string;
|
key: string;
|
||||||
capabilities: MemoryAdapterCapabilities;
|
capabilities: MemoryAdapterCapabilities;
|
||||||
capture(req: MemoryCaptureRequest): Promise<{
|
write(req: MemoryWriteRequest): Promise<{
|
||||||
records?: MemoryRecordHandle[];
|
|
||||||
jobs?: MemoryExtractionJob[];
|
|
||||||
usage?: MemoryUsage[];
|
|
||||||
}>;
|
|
||||||
upsertRecords(req: MemoryRecordWriteRequest): Promise<{
|
|
||||||
records?: MemoryRecordHandle[];
|
records?: MemoryRecordHandle[];
|
||||||
usage?: MemoryUsage[];
|
usage?: MemoryUsage[];
|
||||||
}>;
|
}>;
|
||||||
query(req: MemoryQueryRequest): Promise<MemoryContextBundle>;
|
query(req: MemoryQueryRequest): Promise<MemoryContextBundle>;
|
||||||
list(req: MemoryListRequest): Promise<MemoryListPage>;
|
|
||||||
get(handle: MemoryRecordHandle, scope: MemoryScope): Promise<MemorySnippet | null>;
|
get(handle: MemoryRecordHandle, scope: MemoryScope): Promise<MemorySnippet | null>;
|
||||||
forget(handles: MemoryRecordHandle[], scope: MemoryScope): Promise<{ usage?: MemoryUsage[] }>;
|
forget(handles: MemoryRecordHandle[], scope: MemoryScope): Promise<{ usage?: MemoryUsage[] }>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This contract intentionally does not force a provider to expose its internal graph, file tree, or ontology. It does require enough structure for Paperclip to browse, attribute, and audit what happened.
|
This contract intentionally does not force a provider to expose its internal graph, filesystem, or ontology.
|
||||||
|
|
||||||
## Optional Adapter Surfaces
|
## Optional Adapter Surfaces
|
||||||
|
|
||||||
These should be capability-gated, not required:
|
These should be capability-gated, not required:
|
||||||
|
|
||||||
|
- `browse(scope, filters)` for file-system / graph / timeline inspection
|
||||||
- `correct(handle, patch)` for natural-language correction flows
|
- `correct(handle, patch)` for natural-language correction flows
|
||||||
- `profile(scope)` when the provider can synthesize stable preferences or summaries
|
- `profile(scope)` when the provider can synthesize stable preferences or summaries
|
||||||
- `listExtractionJobs(scope, cursor)` when async extraction needs richer operator visibility
|
- `sync(source)` for connectors or background ingestion
|
||||||
- `retryExtractionJob(jobId)` when a provider supports re-drive
|
|
||||||
- `explain(queryResult)` for providers that can expose retrieval traces
|
- `explain(queryResult)` for providers that can expose retrieval traces
|
||||||
- provider-native browse or graph surfaces exposed through plugin UI
|
|
||||||
|
|
||||||
## Lessons From AWS AgentCore Memory API
|
|
||||||
|
|
||||||
AWS AgentCore Memory is a useful check on whether this plan is too abstract or missing important operational surfaces.
|
|
||||||
|
|
||||||
The broad direction still looks right:
|
|
||||||
|
|
||||||
- AWS splits memory into a control plane (`CreateMemory`, `UpdateMemory`, `ListMemories`) and a data plane (`CreateEvent`, `RetrieveMemoryRecords`, `GetMemoryRecord`, `ListMemoryRecords`)
|
|
||||||
- AWS separates raw interaction capture from curated long-term memory records
|
|
||||||
- AWS supports both provider-managed extraction and self-managed pipelines
|
|
||||||
- AWS treats browse and list operations as first-class APIs, not ad hoc debugging helpers
|
|
||||||
- AWS exposes extraction jobs instead of hiding asynchronous maintenance completely
|
|
||||||
|
|
||||||
That lines up with the Paperclip plan at a high level: provider configuration, scoped writes, scoped retrieval, provider-managed extraction as a capability, and a browse and inspect surface.
|
|
||||||
|
|
||||||
The concrete changes Paperclip should take from AWS are:
|
|
||||||
|
|
||||||
### 1. Keep config APIs separate from runtime traffic
|
|
||||||
|
|
||||||
The rollout should preserve a clean separation between:
|
|
||||||
|
|
||||||
- control-plane APIs for binding CRUD, defaults, overrides, and capability metadata
|
|
||||||
- runtime APIs and tools for capture, record writes, query, list, get, forget, and extraction status
|
|
||||||
|
|
||||||
This keeps governance changes distinct from high-volume memory traffic.
|
|
||||||
|
|
||||||
### 2. Distinguish capture from curated record writes
|
|
||||||
|
|
||||||
AWS does not flatten everything into one write primitive. It distinguishes captured events from durable memory records.
|
|
||||||
|
|
||||||
Paperclip should do the same:
|
|
||||||
|
|
||||||
- `capture(...)` for raw run, comment, document, or activity residue
|
|
||||||
- `upsertRecords(...)` for curated durable facts and notes
|
|
||||||
|
|
||||||
That is a better fit for provider-managed extraction and for manual curation flows.
|
|
||||||
|
|
||||||
### 3. Make list and browse first-class
|
|
||||||
|
|
||||||
AWS exposes list and retrieve surfaces directly. Paperclip should not make browse optional at the portable layer.
|
|
||||||
|
|
||||||
The minimum portable surface should include:
|
|
||||||
|
|
||||||
- `query`
|
|
||||||
- `list`
|
|
||||||
- `get`
|
|
||||||
|
|
||||||
Provider-native graph or file browsing can remain optional beyond that.
|
|
||||||
|
|
||||||
### 4. Add pagination and cursors for operator inspection
|
|
||||||
|
|
||||||
AWS consistently uses pagination on browse-heavy APIs.
|
|
||||||
|
|
||||||
Paperclip should add cursor-based pagination to:
|
|
||||||
|
|
||||||
- record listing
|
|
||||||
- extraction job listing
|
|
||||||
- memory operation explorer APIs
|
|
||||||
|
|
||||||
Prompt hydration can continue to use `topK`, but operator surfaces need cursors.
|
|
||||||
|
|
||||||
### 5. Add explicit session and namespace hints
|
|
||||||
|
|
||||||
AWS uses `actorId`, `sessionId`, `namespace`, and `memoryStrategyId` heavily.
|
|
||||||
|
|
||||||
Paperclip should keep its own control-plane-centric model, but the adapter contract needs obvious places to map those concepts:
|
|
||||||
|
|
||||||
- `sessionKey`
|
|
||||||
- `namespace`
|
|
||||||
|
|
||||||
The provider adapter can map them to AWS or other vendor-specific identifiers without leaking those identifiers into core.
|
|
||||||
|
|
||||||
### 6. Treat asynchronous extraction as a real operational surface
|
|
||||||
|
|
||||||
AWS exposes extraction jobs explicitly. Paperclip should too.
|
|
||||||
|
|
||||||
Operators should be able to see:
|
|
||||||
|
|
||||||
- pending extraction work
|
|
||||||
- failed extraction work
|
|
||||||
- which hook or source caused the work
|
|
||||||
- whether a retry is available
|
|
||||||
|
|
||||||
### 7. Keep Paperclip provenance primary
|
|
||||||
|
|
||||||
Paperclip should continue to center:
|
|
||||||
|
|
||||||
- `companyId`
|
|
||||||
- `agentId`
|
|
||||||
- `projectId`
|
|
||||||
- `issueId`
|
|
||||||
- `runId`
|
|
||||||
- issue comments, documents, and activity as sources
|
|
||||||
|
|
||||||
The lesson from AWS is to support clean mapping into provider-specific models, not to let provider identifiers take over the core product model.
|
|
||||||
|
|
||||||
## What Paperclip Should Persist
|
## What Paperclip Should Persist
|
||||||
|
|
||||||
@@ -456,67 +248,39 @@ Paperclip should not mirror the full provider memory corpus into Postgres unless
|
|||||||
|
|
||||||
Paperclip core should persist:
|
Paperclip core should persist:
|
||||||
|
|
||||||
- memory bindings
|
- memory bindings and overrides
|
||||||
- company default and agent override resolution targets
|
|
||||||
- provider keys and capability metadata
|
- provider keys and capability metadata
|
||||||
- normalized memory operation logs
|
- normalized memory operation logs
|
||||||
- source references back to issue comments, documents, runs, and activity
|
|
||||||
- provider record handles returned by operations when available
|
- provider record handles returned by operations when available
|
||||||
- hook delivery records and extraction job state
|
- source references back to issue comments, documents, runs, and activity
|
||||||
- usage and cost attribution
|
- usage and cost data
|
||||||
|
|
||||||
For external providers, the actual memory payload can remain in the provider.
|
For external providers, the memory payload itself can remain in the provider.
|
||||||
|
|
||||||
## Hook Model
|
## Hook Model
|
||||||
|
|
||||||
### Shared hook surface
|
### Automatic hooks
|
||||||
|
|
||||||
Paperclip should expose one shared hook system for memory.
|
|
||||||
|
|
||||||
That same system must be available to:
|
|
||||||
|
|
||||||
- built-in memory providers
|
|
||||||
- plugin-based memory providers
|
|
||||||
- third-party adapter integrations that want to use memory hooks
|
|
||||||
|
|
||||||
### What a hook delivers
|
|
||||||
|
|
||||||
Each hook delivery should include:
|
|
||||||
|
|
||||||
- resolved binding key
|
|
||||||
- normalized `MemoryScope`
|
|
||||||
- `MemorySourceRef`
|
|
||||||
- structured source payload
|
|
||||||
- hook metadata such as hook kind, trigger time, and related run id
|
|
||||||
|
|
||||||
The payload should include structured objects where possible so the provider can decide how to extract and chunk.
|
|
||||||
|
|
||||||
### Initial automatic hooks
|
|
||||||
|
|
||||||
These should be low-risk and easy to reason about:
|
These should be low-risk and easy to reason about:
|
||||||
|
|
||||||
1. `pre_run_hydrate`
|
1. `pre-run hydrate`
|
||||||
Before an agent run starts, Paperclip may call `query(... intent = "agent_preamble")` using the active binding.
|
Before an agent run starts, Paperclip may call `query(... intent = "agent_preamble")` using the active binding.
|
||||||
|
|
||||||
2. `post_run_capture`
|
2. `post-run capture`
|
||||||
After a run finishes, Paperclip may call `capture(...)` with structured run output, excerpts, and provenance.
|
After a run finishes, Paperclip may write a summary or transcript-derived note tied to the run.
|
||||||
|
|
||||||
3. `issue_comment_capture`
|
3. `issue comment / document capture`
|
||||||
When enabled on the binding, Paperclip may call `capture(...)` for selected issue comments.
|
When enabled on the binding, Paperclip may capture selected issue comments or issue documents as memory sources.
|
||||||
|
|
||||||
4. `issue_document_capture`
|
### Explicit hooks
|
||||||
When enabled on the binding, Paperclip may call `capture(...)` for selected issue documents.
|
|
||||||
|
|
||||||
### Explicit tools and APIs
|
These should be tool- or UI-driven first:
|
||||||
|
|
||||||
These should be tool-driven or UI-driven first:
|
|
||||||
|
|
||||||
- `memory.search`
|
- `memory.search`
|
||||||
- `memory.note`
|
- `memory.note`
|
||||||
- `memory.forget`
|
- `memory.forget`
|
||||||
- `memory.correct`
|
- `memory.correct`
|
||||||
- memory record list and get
|
- `memory.browse`
|
||||||
- extraction-job inspection
|
|
||||||
|
|
||||||
### Not automatic in the first version
|
### Not automatic in the first version
|
||||||
|
|
||||||
@@ -545,69 +309,34 @@ The initial browse surface should support:
|
|||||||
|
|
||||||
- active binding by company and agent
|
- active binding by company and agent
|
||||||
- recent memory operations
|
- recent memory operations
|
||||||
- recent write and capture sources
|
- recent write sources
|
||||||
- record list and record detail with source backlinks
|
|
||||||
- query results with source backlinks
|
- query results with source backlinks
|
||||||
- extraction job status
|
- filters by agent, issue, run, source kind, and date
|
||||||
- filters by agent, issue, project, run, source kind, and date
|
- provider usage / cost / latency summaries
|
||||||
- provider usage, cost, and latency summaries
|
|
||||||
|
|
||||||
When a provider supports richer browsing, the plugin can add deeper views through the existing plugin UI surfaces.
|
When a provider supports richer browsing, the plugin can add deeper views through the existing plugin UI surfaces.
|
||||||
|
|
||||||
## Cost And Evaluation
|
## Cost And Evaluation
|
||||||
|
|
||||||
Paperclip should treat memory accounting as two related but distinct concerns:
|
Every adapter response should be able to return usage records.
|
||||||
|
|
||||||
### 1. `memory_operations` is the authoritative audit trail
|
Paperclip should roll up:
|
||||||
|
|
||||||
Every memory action should create a normalized operation record that captures:
|
- memory inference tokens
|
||||||
|
- embedding tokens
|
||||||
- binding
|
- external provider cost
|
||||||
- scope
|
|
||||||
- source provenance
|
|
||||||
- operation type
|
|
||||||
- success or failure
|
|
||||||
- latency
|
- latency
|
||||||
- usage details reported by the provider
|
- query count
|
||||||
- attribution mode
|
- write count
|
||||||
- related run, issue, and agent when available
|
|
||||||
|
|
||||||
This is where operators answer "what memory work happened and why?"
|
It should also record evaluation-oriented metrics where possible:
|
||||||
|
|
||||||
### 2. `cost_events` remains the canonical spend ledger for billable metered usage
|
|
||||||
|
|
||||||
The current `cost_events` model is already the canonical cost ledger for token and model spend, and `agent_runtime_state` plus `heartbeat_runs.usageJson` already roll up and summarize run usage.
|
|
||||||
|
|
||||||
The recommendation is:
|
|
||||||
|
|
||||||
- if a memory operation runs inside a normal Paperclip agent heartbeat and the model usage is already counted on that run, do not create a duplicate `cost_event`
|
|
||||||
- instead, store the memory operation with `attributionMode = "included_in_run"` and link it to the related `heartbeatRunId`
|
|
||||||
- if a memory provider makes a direct metered model call outside the agent run accounting path, the provider must report usage and Paperclip should create a `cost_event`
|
|
||||||
- that direct `cost_event` should still link back to the memory operation, agent, company, and issue or run context when possible
|
|
||||||
|
|
||||||
### 3. `finance_events` should carry flat subscription or invoice-style costs
|
|
||||||
|
|
||||||
If a memory service incurs:
|
|
||||||
|
|
||||||
- monthly subscription cost
|
|
||||||
- storage invoices
|
|
||||||
- provider platform charges not tied to one request
|
|
||||||
|
|
||||||
those should be represented as `finance_events`, not as synthetic per-query memory operations.
|
|
||||||
|
|
||||||
That keeps usage telemetry separate from accounting entries like invoices and flat fees.
|
|
||||||
|
|
||||||
### 4. Evaluation metrics still matter
|
|
||||||
|
|
||||||
Paperclip should record evaluation-oriented metrics where possible:
|
|
||||||
|
|
||||||
- recall hit rate
|
- recall hit rate
|
||||||
- empty query rate
|
- empty query rate
|
||||||
- manual correction count
|
- manual correction count
|
||||||
- extraction failure count
|
- per-binding success / failure counts
|
||||||
- per-binding success and failure counts
|
|
||||||
|
|
||||||
This is important because a memory system that "works" but silently burns budget or silently fails extraction is not acceptable in Paperclip.
|
This is important because a memory system that "works" but silently burns budget is not acceptable in Paperclip.
|
||||||
|
|
||||||
## Suggested Data Model Additions
|
## Suggested Data Model Additions
|
||||||
|
|
||||||
@@ -615,36 +344,23 @@ At the control-plane level, the likely new core tables are:
|
|||||||
|
|
||||||
- `memory_bindings`
|
- `memory_bindings`
|
||||||
- company-scoped key
|
- company-scoped key
|
||||||
- provider id or plugin id
|
- provider id / plugin id
|
||||||
- config blob
|
- config blob
|
||||||
- enabled status
|
- enabled status
|
||||||
|
|
||||||
- `memory_binding_targets`
|
- `memory_binding_targets`
|
||||||
- target type (`company`, `agent`)
|
- target type (`company`, `agent`, later `project`)
|
||||||
- target id
|
- target id
|
||||||
- binding id
|
- binding id
|
||||||
|
|
||||||
- `memory_operations`
|
- `memory_operations`
|
||||||
- company id
|
- company id
|
||||||
- binding id
|
- binding id
|
||||||
- operation type (`capture`, `record_upsert`, `query`, `list`, `get`, `forget`, `correct`)
|
- operation type (`write`, `query`, `forget`, `browse`, `correct`)
|
||||||
- scope fields
|
- scope fields
|
||||||
- source refs
|
- source refs
|
||||||
- usage, latency, and attribution mode
|
- usage / latency / cost
|
||||||
- related heartbeat run id
|
- success / error
|
||||||
- related cost event id
|
|
||||||
- success or error
|
|
||||||
|
|
||||||
- `memory_extraction_jobs`
|
|
||||||
- company id
|
|
||||||
- binding id
|
|
||||||
- operation id
|
|
||||||
- provider job id
|
|
||||||
- hook kind
|
|
||||||
- status
|
|
||||||
- source refs
|
|
||||||
- error
|
|
||||||
- submitted, started, and finished timestamps
|
|
||||||
|
|
||||||
Provider-specific long-form state should stay in plugin state or the provider itself unless a built-in local provider needs its own schema.
|
Provider-specific long-form state should stay in plugin state or the provider itself unless a built-in local provider needs its own schema.
|
||||||
|
|
||||||
@@ -666,46 +382,45 @@ The design should still treat that built-in as just another provider behind the
|
|||||||
### Phase 1: Control-plane contract
|
### Phase 1: Control-plane contract
|
||||||
|
|
||||||
- add memory binding models and API types
|
- add memory binding models and API types
|
||||||
- add company default plus agent override resolution
|
- add plugin capability / registration surface for memory providers
|
||||||
- add plugin capability and registration surface for memory providers
|
- add operation logging and usage reporting
|
||||||
|
|
||||||
### Phase 2: Hook delivery and operation audit
|
### Phase 2: One built-in + one plugin example
|
||||||
|
|
||||||
- add shared memory hook emission in core
|
|
||||||
- add operation logging, extraction job state, and usage attribution
|
|
||||||
- add direct-provider cost and finance-event linkage rules
|
|
||||||
|
|
||||||
### Phase 3: One built-in plus one plugin example
|
|
||||||
|
|
||||||
- ship a local markdown-first provider
|
- ship a local markdown-first provider
|
||||||
- ship one hosted adapter example to validate the external-provider path
|
- ship one hosted adapter example to validate the external-provider path
|
||||||
|
|
||||||
### Phase 4: UI inspection
|
### Phase 3: UI inspection
|
||||||
|
|
||||||
- add company and agent memory settings
|
- add company / agent memory settings
|
||||||
- add a memory operation explorer
|
- add a memory operation explorer
|
||||||
- add record list and detail surfaces
|
|
||||||
- add source backlinks to issues and runs
|
- add source backlinks to issues and runs
|
||||||
|
|
||||||
|
### Phase 4: Automatic hooks
|
||||||
|
|
||||||
|
- pre-run hydrate
|
||||||
|
- post-run capture
|
||||||
|
- selected issue comment / document capture
|
||||||
|
|
||||||
### Phase 5: Rich capabilities
|
### Phase 5: Rich capabilities
|
||||||
|
|
||||||
- correction flows
|
- correction flows
|
||||||
- provider-native browse or graph views
|
- provider-native browse / graph views
|
||||||
|
- project-level overrides if needed
|
||||||
- evaluation dashboards
|
- evaluation dashboards
|
||||||
- retention and quota controls
|
|
||||||
|
|
||||||
## Remaining Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Which built-in local provider should ship first: pure markdown, markdown plus embeddings, or a lightweight local vector store?
|
- Should project overrides exist in V1 of the memory service, or should we force company default + agent override first?
|
||||||
- How much source payload should Paperclip snapshot inside `memory_operations` for debugging without duplicating large transcripts?
|
- Do we want Paperclip-managed extraction pipelines at all, or should built-ins be the only place where Paperclip owns extraction?
|
||||||
- Should correction flows mutate provider state directly, create superseding records, or both depending on provider capability?
|
- Should memory usage extend the current `cost_events` model directly, or should memory operations keep a parallel usage log and roll up into `cost_events` secondarily?
|
||||||
- What default retention and size limits should the local built-in enforce?
|
- Do we want provider install / binding changes to require approvals for some companies?
|
||||||
|
|
||||||
## Bottom Line
|
## Bottom Line
|
||||||
|
|
||||||
The right abstraction is:
|
The right abstraction is:
|
||||||
|
|
||||||
- Paperclip owns bindings, resolution, hooks, provenance, policy, and attribution.
|
- Paperclip owns memory bindings, scopes, provenance, governance, and usage reporting.
|
||||||
- Providers own extraction, ranking, storage, and provider-native memory semantics.
|
- Providers own extraction, ranking, storage, and provider-native memory semantics.
|
||||||
|
|
||||||
That gives Paperclip a stable memory service without locking the product to one memory philosophy or one vendor, and it integrates the AWS lessons without importing AWS's model into core.
|
That gives Paperclip a stable "memory service" without locking the product to one memory philosophy or one vendor.
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
# VS Code Task Interoperability Plan
|
|
||||||
|
|
||||||
Status: planning only, no code changes
|
|
||||||
Date: 2026-04-12
|
|
||||||
Related issue: `PAP-1377`
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Paperclip should not replace its workspace runtime service model with VS Code tasks.
|
|
||||||
It should add a narrow interoperability layer that can discover and adopt supported entries from `.vscode/tasks.json`.
|
|
||||||
|
|
||||||
The core product model should stay:
|
|
||||||
|
|
||||||
- Paperclip owns long-running workspace services and their desired state
|
|
||||||
- Paperclip shows operators exactly which named thing they are starting or stopping
|
|
||||||
- Paperclip distinguishes long-running services from one-shot jobs
|
|
||||||
|
|
||||||
VS Code tasks should be treated as:
|
|
||||||
|
|
||||||
- an import/discovery format for workspace commands
|
|
||||||
- a convenience for repos that already maintain `tasks.json`
|
|
||||||
- a partial compatibility layer, not a full execution model
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
The current implementation is already service-oriented:
|
|
||||||
|
|
||||||
- project workspaces and execution workspaces can store `workspaceRuntime` config plus `desiredState` and per-service `serviceStates`
|
|
||||||
- the UI renders one control row per configured service and persists start/stop intent
|
|
||||||
- the backend supervises long-running local processes, reuses eligible services, and restores desired services on startup
|
|
||||||
|
|
||||||
Relevant files:
|
|
||||||
|
|
||||||
- `packages/shared/src/types/workspace-runtime.ts`
|
|
||||||
- `server/src/services/workspace-runtime.ts`
|
|
||||||
- `server/src/services/project-workspace-runtime-config.ts`
|
|
||||||
- `ui/src/components/WorkspaceRuntimeControls.tsx`
|
|
||||||
- `ui/src/pages/ProjectWorkspaceDetail.tsx`
|
|
||||||
- `ui/src/pages/ExecutionWorkspaceDetail.tsx`
|
|
||||||
|
|
||||||
This is directionally correct for Paperclip because it gives the control plane an explicit model for service lifecycle, health, reuse, and restart behavior.
|
|
||||||
|
|
||||||
## Problem To Solve
|
|
||||||
|
|
||||||
The current UX is still too raw:
|
|
||||||
|
|
||||||
- operators have to hand-author runtime JSON
|
|
||||||
- a workspace can have multiple attached services, but the higher-level intent is not obvious
|
|
||||||
- start/stop controls are visible in multiple places, which makes it easy to lose track of what is being controlled
|
|
||||||
- there is no interoperability with repos that already define useful local workflows in `.vscode/tasks.json`
|
|
||||||
|
|
||||||
The issue is not that services are the wrong abstraction.
|
|
||||||
The issue is that the configuration surface is too low-level and Paperclip does not yet leverage existing workspace metadata.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Keep Paperclip runtime services as the source of truth for service supervision.
|
|
||||||
Add a new workspace command model above the raw JSON layer, with VS Code task discovery as one input.
|
|
||||||
|
|
||||||
The product model should become:
|
|
||||||
|
|
||||||
1. `Workspace command`
|
|
||||||
A named runnable thing attached to a workspace.
|
|
||||||
|
|
||||||
2. `Workspace service`
|
|
||||||
A workspace command that is expected to stay alive and be supervised.
|
|
||||||
|
|
||||||
3. `Workspace job`
|
|
||||||
A workspace command that runs once and exits.
|
|
||||||
|
|
||||||
4. `Runtime service instance`
|
|
||||||
The live process record that already exists today in Paperclip.
|
|
||||||
|
|
||||||
In that model, VS Code tasks are a way to populate workspace commands.
|
|
||||||
Only commands that map cleanly to Paperclip service or job semantics should become runnable in Paperclip.
|
|
||||||
|
|
||||||
## Why Not Fully Adopt VS Code Tasks
|
|
||||||
|
|
||||||
VS Code tasks are broader than Paperclip runtime services.
|
|
||||||
They include shell/process tasks, compound tasks, background/watch tasks, presentation settings, extension/task-provider types, variable substitution, and problem-matcher-driven lifecycle.
|
|
||||||
|
|
||||||
That creates a bad fit if Paperclip tries to use `tasks.json` as its only runtime model:
|
|
||||||
|
|
||||||
- many tasks are one-shot jobs, not long-running services
|
|
||||||
- some tasks depend on VS Code task providers or editor-only variable resolution
|
|
||||||
- compound task graphs are useful, but they are not the same thing as a supervised service
|
|
||||||
- problem matcher readiness is useful metadata, but it is not enough to replace Paperclip's persisted service lifecycle model
|
|
||||||
|
|
||||||
The right boundary is interoperability, not replacement.
|
|
||||||
|
|
||||||
## Interoperability Contract
|
|
||||||
|
|
||||||
Paperclip should support a conservative subset of VS Code tasks and clearly mark unsupported entries.
|
|
||||||
|
|
||||||
### Supported in phase 1
|
|
||||||
|
|
||||||
- `shell` and `process` tasks with a concrete command Paperclip can resolve
|
|
||||||
- optional task `options.cwd`
|
|
||||||
- optional task environment values that can be flattened safely
|
|
||||||
- task labels and detail text for naming and display
|
|
||||||
- `dependsOn` for import-time expansion or display-only dependency hints
|
|
||||||
- background/watch-oriented tasks that can reasonably be treated as long-running services
|
|
||||||
|
|
||||||
### Maybe supported in later phases
|
|
||||||
|
|
||||||
- grouping and default task metadata for better UX
|
|
||||||
- selected variable substitution when Paperclip can resolve it safely from workspace context
|
|
||||||
- mapping task metadata into Paperclip readiness/expose hints
|
|
||||||
- limited compound-task launch flows
|
|
||||||
|
|
||||||
### Not supported initially
|
|
||||||
|
|
||||||
- extension-provided task types Paperclip cannot execute directly
|
|
||||||
- arbitrary VS Code variable substitution semantics
|
|
||||||
- problem matcher parsing as the main source of service health
|
|
||||||
- full parity with VS Code task execution behavior
|
|
||||||
|
|
||||||
## Long-Running Service Detection
|
|
||||||
|
|
||||||
Paperclip needs an explicit classification layer instead of assuming every VS Code task is a service.
|
|
||||||
|
|
||||||
Recommended classification:
|
|
||||||
|
|
||||||
- `service`
|
|
||||||
Explicitly marked by Paperclip metadata, or confidently inferred from background/watch task semantics
|
|
||||||
|
|
||||||
- `job`
|
|
||||||
One-shot command expected to exit
|
|
||||||
|
|
||||||
- `unsupported`
|
|
||||||
Present in `tasks.json`, but not safely runnable by Paperclip
|
|
||||||
|
|
||||||
The important product decision is that service classification must be visible and editable by the operator.
|
|
||||||
Inference can help, but it should not be the only source of truth.
|
|
||||||
|
|
||||||
## Proposed Product Shape
|
|
||||||
|
|
||||||
### 1. Replace raw-first editing with command-first editing
|
|
||||||
|
|
||||||
Project and execution workspace pages should stop making raw runtime JSON the primary editing surface.
|
|
||||||
|
|
||||||
Default UI should show:
|
|
||||||
|
|
||||||
- workspace commands
|
|
||||||
- command type: service or job
|
|
||||||
- source: Paperclip or VS Code
|
|
||||||
- exact command and cwd
|
|
||||||
- current state for services
|
|
||||||
- explicit start, stop, restart, and run-now actions
|
|
||||||
|
|
||||||
Raw JSON should remain available behind an advanced section.
|
|
||||||
|
|
||||||
### 2. Add VS Code task discovery on workspaces
|
|
||||||
|
|
||||||
For a workspace with `cwd`, Paperclip should look for `.vscode/tasks.json`.
|
|
||||||
|
|
||||||
The workspace UI should show:
|
|
||||||
|
|
||||||
- whether a `tasks.json` file was found
|
|
||||||
- last parse time
|
|
||||||
- supported commands discovered
|
|
||||||
- unsupported tasks with reasons
|
|
||||||
- whether commands are inherited into execution workspaces
|
|
||||||
|
|
||||||
### 3. Make the controlled thing explicit
|
|
||||||
|
|
||||||
Start and stop UI should always name the exact entry being controlled.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `Start web`
|
|
||||||
- `Stop api`
|
|
||||||
- `Run db:migrate`
|
|
||||||
|
|
||||||
Avoid generic workspace-level labels when multiple commands exist.
|
|
||||||
|
|
||||||
### 4. Separate services from jobs in the UI
|
|
||||||
|
|
||||||
Do not mix one-shot jobs and long-running services into one undifferentiated list.
|
|
||||||
|
|
||||||
Recommended sections:
|
|
||||||
|
|
||||||
- `Services`
|
|
||||||
- `Jobs`
|
|
||||||
- `Unsupported imported tasks`
|
|
||||||
|
|
||||||
That resolves the ambiguity called out in the issue.
|
|
||||||
|
|
||||||
## Data Model Direction
|
|
||||||
|
|
||||||
Do not replace `workspaceRuntime` immediately.
|
|
||||||
Instead add a higher-level representation that can compile down to the existing runtime-service machinery.
|
|
||||||
|
|
||||||
Suggested workspace metadata shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type WorkspaceCommandSource =
|
|
||||||
| { type: "paperclip" }
|
|
||||||
| { type: "vscode_task"; taskLabel: string; taskPath: ".vscode/tasks.json" };
|
|
||||||
|
|
||||||
type WorkspaceCommandKind = "service" | "job";
|
|
||||||
|
|
||||||
type WorkspaceCommandDefinition = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
kind: WorkspaceCommandKind;
|
|
||||||
source: WorkspaceCommandSource;
|
|
||||||
command: string | null;
|
|
||||||
cwd: string | null;
|
|
||||||
env?: Record<string, string> | null;
|
|
||||||
autoStart?: boolean;
|
|
||||||
serviceConfig?: {
|
|
||||||
lifecycle?: "shared" | "ephemeral";
|
|
||||||
reuseScope?: "project_workspace" | "execution_workspace" | "run";
|
|
||||||
readiness?: Record<string, unknown> | null;
|
|
||||||
expose?: Record<string, unknown> | null;
|
|
||||||
} | null;
|
|
||||||
importWarnings?: string[];
|
|
||||||
disabledReason?: string | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
`workspaceRuntime` can then become a derived or advanced representation for service-type commands until the rest of the system is migrated.
|
|
||||||
|
|
||||||
## VS Code Mapping Rules
|
|
||||||
|
|
||||||
Paperclip should map imported tasks with explicit, documented rules.
|
|
||||||
|
|
||||||
Recommended rules:
|
|
||||||
|
|
||||||
1. A task becomes a `job` by default.
|
|
||||||
2. A task becomes a `service` only when:
|
|
||||||
- Paperclip metadata marks it as a service, or
|
|
||||||
- the task clearly represents a background/watch process and the operator confirms the classification.
|
|
||||||
3. Unsupported tasks stay visible but disabled.
|
|
||||||
4. Task labels become default command names.
|
|
||||||
5. `dependsOn` is preserved as metadata, not silently flattened into hidden behavior.
|
|
||||||
|
|
||||||
Paperclip-specific metadata can live in a namespaced field on the imported task definition, for example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"label": "web",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "pnpm dev",
|
|
||||||
"isBackground": true,
|
|
||||||
"paperclip": {
|
|
||||||
"kind": "service",
|
|
||||||
"readiness": {
|
|
||||||
"type": "http",
|
|
||||||
"urlTemplate": "http://127.0.0.1:${port}"
|
|
||||||
},
|
|
||||||
"expose": {
|
|
||||||
"type": "url",
|
|
||||||
"urlTemplate": "http://127.0.0.1:${port}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That gives us interoperability without depending on VS Code-only semantics for service readiness and exposure.
|
|
||||||
|
|
||||||
## Execution Policy
|
|
||||||
|
|
||||||
Project workspaces should be the main place where imported commands are discovered and curated.
|
|
||||||
Execution workspaces should inherit that curated command set by default, with optional issue-level overrides.
|
|
||||||
|
|
||||||
Recommended precedence:
|
|
||||||
|
|
||||||
1. execution workspace override
|
|
||||||
2. project workspace command set
|
|
||||||
3. imported VS Code tasks from the linked workspace
|
|
||||||
4. advanced raw runtime fallback
|
|
||||||
|
|
||||||
This matches the existing direction in `doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md`.
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Discovery and read-only visibility
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
show imported VS Code tasks in the workspace UI without changing runtime behavior.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
- parse `.vscode/tasks.json` for project workspaces with local `cwd`
|
|
||||||
- derive a list of candidate commands plus unsupported items
|
|
||||||
- show source, label, command, cwd, and classification
|
|
||||||
- show parse warnings and unsupported reasons
|
|
||||||
|
|
||||||
Success condition:
|
|
||||||
an operator can see what Paperclip would import and why.
|
|
||||||
|
|
||||||
### Phase 2: Command model and explicit classification
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
introduce a first-class workspace command layer above raw runtime JSON.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
- add a persisted command definition model in workspace metadata or a dedicated table
|
|
||||||
- allow operator edits to imported command classification
|
|
||||||
- separate `service` and `job` in UI
|
|
||||||
- keep existing runtime-service storage for live supervised processes
|
|
||||||
|
|
||||||
Success condition:
|
|
||||||
the workspace UI is command-first, and raw runtime JSON is advanced-only.
|
|
||||||
|
|
||||||
### Phase 3: Service execution backed by existing runtime supervisor
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
run supported imported service commands through the current Paperclip supervisor.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
- compile service commands into the existing runtime service start/stop path
|
|
||||||
- persist desired state per named command
|
|
||||||
- keep startup restoration behavior for service commands
|
|
||||||
- make the active command name explicit everywhere control actions appear
|
|
||||||
|
|
||||||
Success condition:
|
|
||||||
imported service commands behave like native Paperclip services once adopted.
|
|
||||||
|
|
||||||
### Phase 4: Job execution and optional dependency handling
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
support one-shot imported commands without pretending they are services.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
- add `Run` actions for jobs
|
|
||||||
- record output in workspace operations
|
|
||||||
- optionally support simple `dependsOn` execution for jobs with clear logging
|
|
||||||
|
|
||||||
Success condition:
|
|
||||||
one-shot tasks are runnable, but they are not mixed into the service lifecycle model.
|
|
||||||
|
|
||||||
### Phase 5: Adapter and execution workspace integration
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
let agents and issue-scoped workspaces consume the curated command model consistently.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
- expose inherited workspace commands to execution workspaces
|
|
||||||
- allow issue-level selection of a default service command when relevant
|
|
||||||
- make service selection explicit in issue and workspace views
|
|
||||||
|
|
||||||
Success condition:
|
|
||||||
agents, operators, and workspaces all refer to the same named commands.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- full VS Code task-runner parity
|
|
||||||
- support for every VS Code task type
|
|
||||||
- removal of Paperclip's own runtime supervision model
|
|
||||||
- editor-dependent execution semantics inside the control plane
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- overfitting Paperclip to VS Code and making the model worse for non-VS-Code repos
|
|
||||||
- misclassifying watch tasks as durable services
|
|
||||||
- hiding too much detail and making debugging harder
|
|
||||||
- allowing imported task graphs to become implicit magic
|
|
||||||
|
|
||||||
These risks are manageable if the import layer stays explicit, conservative, and operator-editable.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Paperclip should adopt VS Code tasks as an optional workspace command source, not as the canonical runtime model.
|
|
||||||
|
|
||||||
The main UX change should be:
|
|
||||||
|
|
||||||
- move from raw runtime JSON to named workspace commands
|
|
||||||
- separate services from jobs
|
|
||||||
- make the exact controlled command explicit
|
|
||||||
- let `.vscode/tasks.json` pre-populate those commands when available
|
|
||||||
|
|
||||||
## External References
|
|
||||||
|
|
||||||
- VS Code tasks documentation: https://code.visualstudio.com/docs/debugtest/tasks
|
|
||||||
- Existing Paperclip workspace plan: `doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md`
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Plugin Secret Refs: Company Scope Reintroduction Plan
|
|
||||||
|
|
||||||
Date: 2026-04-26
|
|
||||||
Status: follow-up after fail-closed mitigation
|
|
||||||
Related issue: PAP-2394
|
|
||||||
|
|
||||||
## Current state
|
|
||||||
|
|
||||||
`PAP-2394` now fails closed:
|
|
||||||
|
|
||||||
- `POST /api/plugins/:pluginId/config` rejects any config containing plugin secret refs.
|
|
||||||
- `ctx.secrets.resolve()` is disabled for plugin workers.
|
|
||||||
|
|
||||||
This removes the release-blocking cross-company exposure path, but it also disables plugin secret-ref support until the runtime carries company scope end to end.
|
|
||||||
|
|
||||||
## Vulnerability summary
|
|
||||||
|
|
||||||
The original design mixed an instance-global config store with company-scoped secret bindings:
|
|
||||||
|
|
||||||
- [server/src/routes/plugins.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/routes/plugins.ts:1898) saved one global plugin config row, then wrote bindings into `company_secret_bindings` grouped by each referenced secret's owning company.
|
|
||||||
- [packages/db/src/schema/plugin_config.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/plugin_config.ts:15) stored one config row per plugin, with no company dimension.
|
|
||||||
- [packages/db/src/schema/company_secret_bindings.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/company_secret_bindings.ts:5) already modeled bindings as company-scoped.
|
|
||||||
- [server/src/services/plugin-secrets-handler.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/services/plugin-secrets-handler.ts:212) resolved by `pluginId` + secret UUID, with no active company context from the bridge call.
|
|
||||||
- [packages/plugins/sdk/src/worker-rpc-host.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/plugins/sdk/src/worker-rpc-host.ts:384) exposed `ctx.config.get()` and `ctx.secrets.resolve()` without a company parameter.
|
|
||||||
|
|
||||||
This violated Least Privilege, Complete Mediation, and Secure Defaults.
|
|
||||||
|
|
||||||
## Recommended end state
|
|
||||||
|
|
||||||
Re-enable plugin secret refs only after both of these are true:
|
|
||||||
|
|
||||||
1. Plugin config reads/writes are company-scoped.
|
|
||||||
2. Runtime secret resolution carries explicit company context and enforces it at resolution time.
|
|
||||||
|
|
||||||
## Implementation plan
|
|
||||||
|
|
||||||
### 1. Make plugin config company-scoped
|
|
||||||
|
|
||||||
- Add `company_id` to `plugin_config`, with a unique index on `(plugin_id, company_id)`.
|
|
||||||
- Update registry helpers to require `companyId` for `getConfig`, `upsertConfig`, `patchConfig`, and `deleteConfig`.
|
|
||||||
- Update plugin config routes to require `companyId` and call `assertCompanyAccess(req, companyId)`.
|
|
||||||
- Keep instance-global plugin lifecycle state separate from company-scoped plugin config.
|
|
||||||
|
|
||||||
### 2. Propagate company context through the worker runtime
|
|
||||||
|
|
||||||
- Extend the SDK so `ctx.config.get()` and `ctx.secrets.resolve()` can receive or derive `companyId`.
|
|
||||||
- Introduce worker request context storage for handlers that already run with company scope:
|
|
||||||
- `getData`
|
|
||||||
- `performAction`
|
|
||||||
- scoped API routes
|
|
||||||
- tool executions
|
|
||||||
- environment driver calls
|
|
||||||
- Fail closed when plugin code tries to read company-scoped config or secrets outside an active company context.
|
|
||||||
|
|
||||||
### 3. Rebind secrets by `(companyId, pluginId, configPath)`
|
|
||||||
|
|
||||||
- On config save, validate every referenced secret belongs to the authorized company.
|
|
||||||
- Store bindings only for that company.
|
|
||||||
- Resolve secrets only by the current company-scoped binding, never by bare plugin ID plus UUID.
|
|
||||||
- Treat stale bindings as invalid and remove them on config replacement.
|
|
||||||
|
|
||||||
### 4. Prevent cross-company config disclosure
|
|
||||||
|
|
||||||
- When returning config to the UI, only materialize the selected company's secret refs.
|
|
||||||
- Never expose another company's secret UUIDs through the global plugin config surface.
|
|
||||||
|
|
||||||
## Required regression coverage
|
|
||||||
|
|
||||||
- Company A board user cannot save plugin config that references a Company B secret.
|
|
||||||
- Company A plugin execution cannot resolve a Company B secret even if the same plugin is configured for Company B.
|
|
||||||
- Company-scoped config reads only return the selected company's secret bindings.
|
|
||||||
- Config replacement removes stale bindings for the same `(companyId, pluginId)` target.
|
|
||||||
- Runtime calls without company context fail closed.
|
|
||||||
|
|
||||||
## Migration notes
|
|
||||||
|
|
||||||
- Existing `plugin_config` rows need a migration strategy before re-enable.
|
|
||||||
- Safest default: do not auto-assume a company for historical secret refs.
|
|
||||||
- Prefer one of:
|
|
||||||
- explicit admin migration per company, or
|
|
||||||
- import existing rows as non-secret config only and require re-entry of secret refs.
|
|
||||||
|
|
||||||
## Release posture
|
|
||||||
|
|
||||||
- Keep plugin secret refs disabled until all steps above land.
|
|
||||||
- Do not restore the feature behind a soft warning; the insecure path must remain unavailable by default.
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# LLM Wiki Paperclip Asset And Work-Product Security Gate
|
|
||||||
|
|
||||||
Status: accepted Phase 5 policy
|
|
||||||
Date: 2026-05-06
|
|
||||||
Owner: Security engineering
|
|
||||||
Scope: Paperclip-derived ingestion into the LLM Wiki before any asset or work-product content indexing ships
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Phase 5 remains **fail-closed** for Paperclip assets and work products.
|
|
||||||
|
|
||||||
- Paperclip-derived **text extraction is allowed only** for issue titles/descriptions, issue comments, and issue documents.
|
|
||||||
- Paperclip **assets/attachments** and **issue work products** are **metadata-only** in Phase 5.
|
|
||||||
- **Linked summaries** and **content extraction** for assets/work products are **not approved** in Phase 5.
|
|
||||||
- No implementation may fetch `/api/assets/:id/content`, dereference a work-product `url`, scrape preview pages, or embed binary/blob content into source bundles or source snapshots.
|
|
||||||
|
|
||||||
This keeps the secure path easier than the insecure one and avoids broadening the wiki into a second content-distribution channel.
|
|
||||||
|
|
||||||
## Allowed Source Kinds
|
|
||||||
|
|
||||||
These source kinds may contribute body text to Paperclip-derived source bundles:
|
|
||||||
|
|
||||||
| Source kind | Allowed body fields | Reason |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Issue | `title`, `description`, identifier/status metadata | First-party Paperclip text under company ACL |
|
|
||||||
| Comment | `body` | First-party Paperclip text under company ACL |
|
|
||||||
| Document | `body`, `title`, `key`, revision metadata | First-party Paperclip text under company ACL |
|
|
||||||
|
|
||||||
## Assets And Work Products
|
|
||||||
|
|
||||||
### Assets / attachments
|
|
||||||
|
|
||||||
Allowed in Phase 5:
|
|
||||||
|
|
||||||
- metadata-only references built from allowlisted structured fields already stored in Paperclip
|
|
||||||
- recommended fields: `issueId`, `issueCommentId`, `attachmentId`, `assetId`, `originalFilename`, `contentType`, `byteSize`, `sha256`, `createdAt`, `createdByAgentId`, `createdByUserId`
|
|
||||||
|
|
||||||
Disallowed in Phase 5:
|
|
||||||
|
|
||||||
- fetching asset bytes from `/api/assets/:id/content`
|
|
||||||
- parsing any blob body, including `text/plain`, `text/markdown`, `application/json`, images, SVG, PDFs, archives, or office formats
|
|
||||||
- storing `contentPath` in wiki source bundles or source snapshots
|
|
||||||
- model summarization of attachment bodies
|
|
||||||
|
|
||||||
### Work products
|
|
||||||
|
|
||||||
Allowed in Phase 5:
|
|
||||||
|
|
||||||
- metadata-only references built from allowlisted structured fields already stored in Paperclip
|
|
||||||
- recommended fields: `issueId`, `workProductId`, `type`, `provider`, `title`, `status`, `reviewState`, `healthStatus`, `externalId`, `isPrimary`, `createdAt`, `updatedAt`
|
|
||||||
- optional boolean/derived metadata such as `hasUrl: true`
|
|
||||||
|
|
||||||
Disallowed in Phase 5:
|
|
||||||
|
|
||||||
- fetching or crawling the work-product `url`
|
|
||||||
- scraping preview pages, artifacts, pull requests, branches, commits, or custom provider targets through the wiki ingestion path
|
|
||||||
- storing raw `url` values in wiki source bundles or source snapshots
|
|
||||||
- model-authored linked summaries derived from off-record content
|
|
||||||
|
|
||||||
## MIME Allowlists And Size Caps
|
|
||||||
|
|
||||||
No MIME allowlist is approved for asset content extraction in Phase 5 because **no asset body extraction is approved at all**.
|
|
||||||
|
|
||||||
- Every asset MIME type is treated as opaque for Paperclip-derived indexing.
|
|
||||||
- Existing upload limits remain storage concerns, not ingestion approvals.
|
|
||||||
- Work-product destinations are also opaque regardless of MIME type or size.
|
|
||||||
|
|
||||||
Any future issue that wants blob parsing must define:
|
|
||||||
|
|
||||||
- a positive MIME allowlist
|
|
||||||
- per-type parser strategy
|
|
||||||
- per-source size caps
|
|
||||||
- sandbox/isolation requirements
|
|
||||||
- prompt-injection handling
|
|
||||||
- regression tests for refusal paths
|
|
||||||
|
|
||||||
## Redaction Rules
|
|
||||||
|
|
||||||
Metadata-only means **structured facts only**, not capability-bearing links.
|
|
||||||
|
|
||||||
- Do not persist `contentPath` for assets.
|
|
||||||
- Do not persist raw work-product `url` values.
|
|
||||||
- Do not persist query strings, fragments, signed URL tokens, or userinfo.
|
|
||||||
- Prefer stable identifiers (`assetId`, `workProductId`, `externalId`) over links.
|
|
||||||
|
|
||||||
This addresses Sensitive Information Disclosure, Unsafe Consumption of APIs, and Insecure Output Handling risks.
|
|
||||||
|
|
||||||
## Provenance Rules
|
|
||||||
|
|
||||||
Every metadata-only reference must preserve enough provenance to explain where it came from without reading the underlying content:
|
|
||||||
|
|
||||||
- `companyId`
|
|
||||||
- `issueId`
|
|
||||||
- attachment/work-product id
|
|
||||||
- producer identity when available
|
|
||||||
- timestamps
|
|
||||||
- an explicit `metadata_only` marker in any future reference/snapshot schema
|
|
||||||
|
|
||||||
## Review-Required Behavior
|
|
||||||
|
|
||||||
Human review is **not** required for plain metadata-only references that stay inside the allowlisted fields above.
|
|
||||||
|
|
||||||
Human review **is required**, with a separate security sign-off issue, before enabling any of the following:
|
|
||||||
|
|
||||||
- asset body extraction
|
|
||||||
- work-product URL fetching
|
|
||||||
- linked summaries generated from asset/work-product content
|
|
||||||
- storing raw blob links or raw remote URLs in wiki source material
|
|
||||||
- non-default-space routing for Paperclip-derived asset/work-product references
|
|
||||||
|
|
||||||
## Security Rationale
|
|
||||||
|
|
||||||
This gate exists because the current host surfaces have different trust properties:
|
|
||||||
|
|
||||||
- issue/comment/document text is first-party Paperclip content already exposed through company-scoped issue/document APIs
|
|
||||||
- asset content is a blob download surface (`/api/assets/:id/content`) and can carry prompt-injection or parser-risk payloads
|
|
||||||
- work products can point at arbitrary destinations through `url`, which reintroduces SSRF, token leakage, and prompt-injection risk if dereferenced automatically
|
|
||||||
|
|
||||||
Relevant threat classes:
|
|
||||||
|
|
||||||
- OWASP LLM Top 10: Prompt Injection, Sensitive Information Disclosure, Insecure Output Handling, Excessive Agency
|
|
||||||
- OWASP API Top 10: SSRF, Unsafe Consumption of APIs, Broken Object Property Level Authorization
|
|
||||||
- Saltzer & Schroeder: Least Privilege, Fail Securely, Complete Mediation, Secure Defaults
|
|
||||||
|
|
||||||
## Follow-Up Implementation Scope
|
|
||||||
|
|
||||||
A follow-up implementation issue is justified only for **metadata-only references**.
|
|
||||||
|
|
||||||
That implementation must:
|
|
||||||
|
|
||||||
- keep assets/work products out of source-bundle body text
|
|
||||||
- never fetch blob bytes or remote URLs
|
|
||||||
- redact capability-bearing link fields
|
|
||||||
- mark references as `metadata_only`
|
|
||||||
- ship tests proving source bundles/snapshots never contain `contentPath` or raw work-product `url` fields
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# Local Plugin Development
|
|
||||||
|
|
||||||
This is the short happy-path guide for developing a Paperclip plugin from a folder on your machine. You will scaffold a plugin, run it in watch mode, install it into a running Paperclip instance from an absolute local path, and edit code with the plugin worker reloading after each rebuild.
|
|
||||||
|
|
||||||
For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Node.js 22+ and `pnpm`.
|
|
||||||
- A local Paperclip checkout you can run from source. Local plugin installs read source from disk, so the running server must be able to see the path you give it.
|
|
||||||
|
|
||||||
## The five steps
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Start Paperclip locally
|
|
||||||
pnpm paperclipai run
|
|
||||||
|
|
||||||
# 2. Scaffold a plugin outside the Paperclip repo
|
|
||||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
|
||||||
|
|
||||||
# 3. Install dependencies and start the watch build
|
|
||||||
cd ~/dev/paperclip-plugins/hello-plugin
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# 4. In another terminal, install the plugin from its absolute path
|
|
||||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
|
||||||
|
|
||||||
# 5. Confirm it loaded
|
|
||||||
paperclipai plugin list
|
|
||||||
paperclipai plugin inspect acme.hello-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
That's the loop. The rest of this page explains what each step does and what to expect when you edit code.
|
|
||||||
|
|
||||||
### 1. Start Paperclip
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm paperclipai run
|
|
||||||
```
|
|
||||||
|
|
||||||
Paperclip listens on `http://127.0.0.1:3100` by default. The CLI talks to that server, so leave it running.
|
|
||||||
|
|
||||||
### 2. Scaffold the plugin
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates `~/dev/paperclip-plugins/hello-plugin/` with `src/manifest.ts`, `src/worker.ts`, `src/ui/index.tsx`, an esbuild watch config, a Vitest config, and a snapshot of `@paperclipai/plugin-sdk` from your local Paperclip checkout. You can run the package and tests without publishing anything to npm.
|
|
||||||
|
|
||||||
Useful flags:
|
|
||||||
|
|
||||||
- `--template <default|connector|workspace|environment>` — starter shape.
|
|
||||||
- `--category <connector|workspace|automation|ui|environment>` — manifest category.
|
|
||||||
- `--display-name`, `--description`, `--author` — manifest metadata.
|
|
||||||
- `--sdk-path <absolute-path>` — point at a specific `packages/plugins/sdk` checkout if you have more than one.
|
|
||||||
|
|
||||||
When `plugin init` finishes, it prints the next four commands literally. You can copy them.
|
|
||||||
|
|
||||||
### 3. Install dependencies and run the watch build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/dev/paperclip-plugins/hello-plugin
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
`pnpm dev` runs `esbuild --watch` against the plugin source and emits `dist/manifest.js`, `dist/worker.js`, and `dist/ui/`. Leave it running. Every time you save, esbuild rebuilds the affected output file.
|
|
||||||
|
|
||||||
If your plugin has UI and you want a browser-side dev server with hot module replacement during local UI iteration, run `pnpm dev:ui` in a second terminal. It serves `dist/ui/` on `http://127.0.0.1:4177`. This is optional; Paperclip can load the built UI directly from `dist/ui/` without it.
|
|
||||||
|
|
||||||
### 4. Install from the absolute path
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
The CLI auto-detects local paths (anything that looks absolute, starts with `./`, `../`, or `~`, or resolves to an existing folder relative to the current directory) and sends `{ isLocalPath: true }` to `POST /api/plugins/install` with the resolved absolute path. If you want to be explicit, pass `--local`.
|
|
||||||
|
|
||||||
You will see a confirmation like:
|
|
||||||
|
|
||||||
```
|
|
||||||
Installing plugin from local path: /Users/you/dev/paperclip-plugins/hello-plugin
|
|
||||||
✓ Installed acme.hello-plugin v0.1.0 (ready)
|
|
||||||
Local plugin installs run trusted local code from your machine.
|
|
||||||
Keep `pnpm dev` running in /Users/you/dev/paperclip-plugins/hello-plugin;
|
|
||||||
Paperclip watches rebuilt dist output and reloads the plugin worker.
|
|
||||||
```
|
|
||||||
|
|
||||||
Relative paths are resolved against the current working directory, so `paperclipai plugin install .` from inside the plugin folder works too.
|
|
||||||
|
|
||||||
### 5. Inspect
|
|
||||||
|
|
||||||
```bash
|
|
||||||
paperclipai plugin list
|
|
||||||
paperclipai plugin inspect acme.hello-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
`list` shows plugin key, status, version, and short error. `inspect` prints the same record with the full last error if there is one. Both accept `--json` if you want to script against them.
|
|
||||||
|
|
||||||
## Reload semantics, honestly
|
|
||||||
|
|
||||||
Paperclip watches the on-disk plugin package after a local install. The watcher targets the runtime entrypoints declared in the package's `paperclipPlugin` field (`dist/manifest.js`, `dist/worker.js`, `dist/ui/`).
|
|
||||||
|
|
||||||
What that means in practice:
|
|
||||||
|
|
||||||
- **Worker code:** save a `.ts` file → esbuild rewrites `dist/worker.js` → Paperclip debounces ~500ms and restarts the plugin worker. The next worker call uses the new code. There is no in-process hot module replacement for worker code; it is a worker restart.
|
|
||||||
- **Manifest:** save `src/manifest.ts` → `dist/manifest.js` rewrites → the worker restarts and the host re-reads the manifest.
|
|
||||||
- **Plugin UI:** save a `.tsx` file → esbuild rewrites `dist/ui/` → Paperclip reloads the UI bundle on its next mount. To get HMR during UI iteration, run `pnpm dev:ui` and point at the dev server with `devUiUrl` in your manifest while developing.
|
|
||||||
- **Without `pnpm dev`:** the watcher only fires on `dist/*` changes. If you stop the watch build, source edits do not reach Paperclip. Restart `pnpm dev` (or run `pnpm build` once) before expecting changes.
|
|
||||||
- **`node_modules`, `.git`, `.paperclip-sdk`, and other dotfolders are ignored.** Adding a dependency requires the new code to actually be imported and rebuilt before the worker sees it.
|
|
||||||
|
|
||||||
The server never compiles plugin source for you. The package's own build scripts own that step.
|
|
||||||
|
|
||||||
## Local path plugins vs npm packages
|
|
||||||
|
|
||||||
Both go through the same install endpoint, but they mean different things:
|
|
||||||
|
|
||||||
- **Local path plugins are trusted local code.** Paperclip executes worker code from disk under the same trust boundary as the rest of the running instance. This is meant for developing or operating a plugin against a checkout you control. There is no signature check, no sandboxing of worker code, and no provenance metadata beyond the path. Do not install local-path plugins you did not write.
|
|
||||||
- **npm packages are the deployable artifact.** `paperclipai plugin install @acme/plugin-foo` (optionally `--version 1.2.3`) installs from your configured npm registry, version-pins, and produces an install record that other operators can reproduce. Ship plugins this way.
|
|
||||||
|
|
||||||
When you are done iterating locally, publish the package and reinstall the npm-package form so the install reflects what you will ship.
|
|
||||||
|
|
||||||
## Common things to do next
|
|
||||||
|
|
||||||
- **Restart cleanly:** `paperclipai plugin disable <key>` pauses the plugin without removing it. `paperclipai plugin enable <key>` brings it back. `paperclipai plugin uninstall <key>` removes the install record; add `--force` to also purge plugin state and settings.
|
|
||||||
- **Browse examples:** `paperclipai plugin examples` lists the bundled example plugins that ship with the repo, each with a ready-to-run `paperclipai plugin install <path>` line.
|
|
||||||
- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines, 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.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect <key>` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. The Paperclip server log has the full validation error.
|
|
||||||
- **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them.
|
|
||||||
- **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development.
|
|
||||||
- **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells.
|
|
||||||
@@ -4,31 +4,34 @@ This guide describes the current, implemented way to create a Paperclip plugin i
|
|||||||
|
|
||||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||||
|
|
||||||
> **New to plugins?** Start with the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) — it walks the CLI happy path (`plugin init` → `pnpm dev` → `plugin install <path>`) end to end. Come back here for the full manifest surface, worker capabilities, and UI components.
|
|
||||||
|
|
||||||
## Current reality
|
## Current reality
|
||||||
|
|
||||||
- Treat plugin workers and plugin UI as trusted code.
|
- Treat plugin workers and plugin UI as trusted code.
|
||||||
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
||||||
- Worker-side host APIs are capability-gated.
|
- Worker-side host APIs are capability-gated.
|
||||||
- Plugin UI is not sandboxed by manifest capabilities.
|
- Plugin UI is not sandboxed by manifest capabilities.
|
||||||
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
- There is no host-provided shared React component kit for plugins yet.
|
||||||
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
|
|
||||||
only under `/api/plugins/:pluginId/api/*`.
|
|
||||||
- The host provides a small shared React component kit through
|
|
||||||
`@paperclipai/plugin-sdk/ui`; use it for common Paperclip controls before
|
|
||||||
building custom versions.
|
|
||||||
- `ctx.assets` is not supported in the current runtime.
|
- `ctx.assets` is not supported in the current runtime.
|
||||||
|
|
||||||
## Scaffold a plugin
|
## Scaffold a plugin
|
||||||
|
|
||||||
Use the CLI scaffold command:
|
Use the scaffold package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paperclipai plugin init @yourscope/plugin-name --output /absolute/path/to/plugin-repos
|
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||||
|
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||||
```
|
```
|
||||||
|
|
||||||
That creates `<output>/plugin-name/` with:
|
For a plugin that lives outside the Paperclip repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||||
|
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||||
|
--output /absolute/path/to/plugin-repos \
|
||||||
|
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
That creates a package with:
|
||||||
|
|
||||||
- `src/manifest.ts`
|
- `src/manifest.ts`
|
||||||
- `src/worker.ts`
|
- `src/worker.ts`
|
||||||
@@ -39,13 +42,11 @@ That creates `<output>/plugin-name/` with:
|
|||||||
|
|
||||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||||
|
|
||||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first. Pass `--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk` if you have more than one Paperclip checkout.
|
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||||
|
|
||||||
## Local development workflow
|
## Recommended local workflow
|
||||||
|
|
||||||
See the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) for the full happy path (`pnpm dev` → `paperclipai plugin install <absolute-path>` → `paperclipai plugin list`) and reload semantics.
|
From the generated plugin folder:
|
||||||
|
|
||||||
Minimum verification from the generated plugin folder:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -54,6 +55,16 @@ pnpm test
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||||
|
```
|
||||||
|
|
||||||
## Supported alpha surface
|
## Supported alpha surface
|
||||||
|
|
||||||
Worker:
|
Worker:
|
||||||
@@ -66,14 +77,11 @@ Worker:
|
|||||||
- secrets
|
- secrets
|
||||||
- activity
|
- activity
|
||||||
- state
|
- state
|
||||||
- database namespace via `ctx.db`
|
|
||||||
- scoped JSON API routes declared with `apiRoutes`
|
|
||||||
- entities
|
- entities
|
||||||
- projects, project workspaces, and plugin-managed projects
|
- projects and project workspaces
|
||||||
- companies
|
- companies
|
||||||
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
|
- issues and comments
|
||||||
- agents, plugin-managed agents, and agent sessions
|
- agents and agent sessions
|
||||||
- plugin-managed routines
|
|
||||||
- goals
|
- goals
|
||||||
- data/actions
|
- data/actions
|
||||||
- streams
|
- streams
|
||||||
@@ -81,210 +89,6 @@ Worker:
|
|||||||
- metrics
|
- metrics
|
||||||
- logger
|
- logger
|
||||||
|
|
||||||
### Plugin database declarations
|
|
||||||
|
|
||||||
First-party or otherwise trusted orchestration plugins can declare:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
database: {
|
|
||||||
migrationsDir: "migrations",
|
|
||||||
coreReadTables: ["issues"],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Required capabilities are `database.namespace.migrate` and
|
|
||||||
`database.namespace.read`; add `database.namespace.write` for runtime mutations.
|
|
||||||
The host derives `ctx.db.namespace`, runs SQL files in filename order before the
|
|
||||||
worker starts, records checksums in `plugin_migrations`, and rejects changed
|
|
||||||
already-applied migrations.
|
|
||||||
|
|
||||||
Migration SQL may create or alter objects only inside `ctx.db.namespace`. It may
|
|
||||||
reference whitelisted `public` core tables for foreign keys or read-only views,
|
|
||||||
but may not mutate/alter/drop/truncate public tables, create extensions,
|
|
||||||
triggers, untrusted languages, or runtime multi-statement SQL. Runtime
|
|
||||||
`ctx.db.query()` is restricted to `SELECT`; runtime `ctx.db.execute()` is
|
|
||||||
restricted to namespace-local `INSERT`, `UPDATE`, and `DELETE`.
|
|
||||||
|
|
||||||
### Scoped plugin API routes
|
|
||||||
|
|
||||||
Plugins can expose JSON-only routes under their own namespace:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
apiRoutes: [
|
|
||||||
{
|
|
||||||
routeKey: "initialize",
|
|
||||||
method: "POST",
|
|
||||||
path: "/issues/:issueId/smoke",
|
|
||||||
auth: "board-or-agent",
|
|
||||||
capability: "api.routes.register",
|
|
||||||
checkoutPolicy: "required-for-agent-in-progress",
|
|
||||||
companyResolution: { from: "issue", param: "issueId" },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
The host resolves the plugin, checks that it is ready, enforces
|
|
||||||
`api.routes.register`, matches the declared method/path, resolves company access,
|
|
||||||
and applies checkout policy before dispatching to the worker's `onApiRequest`
|
|
||||||
handler. The worker receives sanitized headers, route params, query, parsed JSON
|
|
||||||
body, actor context, and company id. Do not use plugin routes to claim core
|
|
||||||
paths; they always remain under `/api/plugins/:pluginId/api/*`.
|
|
||||||
|
|
||||||
## Managed Paperclip resources
|
|
||||||
|
|
||||||
Plugins that provide durable Paperclip business objects should declare them in
|
|
||||||
the manifest and let the host create or relink the actual records per company.
|
|
||||||
Do this for plugin-owned agents, plugin-owned projects, and recurring automation.
|
|
||||||
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.
|
|
||||||
|
|
||||||
Use these surfaces:
|
|
||||||
|
|
||||||
- Managed agents: declare top-level `agents[]` and require
|
|
||||||
`agents.managed`. Use this when the plugin provides a named worker the board
|
|
||||||
should see in the org, budget, pause, invoke, and inspect. Managed agents are
|
|
||||||
normal Paperclip agents with plugin ownership metadata, not background plugin
|
|
||||||
workers.
|
|
||||||
- Managed projects: declare top-level `projects[]` and require
|
|
||||||
`projects.managed`. Use this when the plugin needs a stable company-scoped
|
|
||||||
project for its issues, routines, or workspace-oriented UI. Keep plugin work
|
|
||||||
in a project instead of scattering generated issues across unrelated projects.
|
|
||||||
- Managed routines: declare top-level `routines[]` and require
|
|
||||||
`routines.managed`. Use this for scheduled, webhook, or manually triggered
|
|
||||||
jobs that should create visible Paperclip issues. Prefer managed routines over
|
|
||||||
plugin `jobs[]` for recurring business work; plugin jobs are for plugin
|
|
||||||
runtime maintenance that does not need a board-visible task trail.
|
|
||||||
|
|
||||||
Managed 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
|
|
||||||
the current `companyId`. `reconcile()` creates the missing resource, relinks a
|
|
||||||
recoverable binding, or returns the existing resource. `reset()` reapplies the
|
|
||||||
manifest defaults when the operator wants to restore the plugin's suggested
|
|
||||||
configuration.
|
|
||||||
|
|
||||||
Declare dependencies between managed resources with refs. A routine can point
|
|
||||||
at a managed agent through `assigneeRef` and at a managed project through
|
|
||||||
`projectRef`. Reconcile the referenced agent and project before reconciling the
|
|
||||||
routine; if a ref is still missing, the routine resolution reports
|
|
||||||
`missing_refs` instead of guessing.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
|
||||||
|
|
||||||
const manifest: PaperclipPluginManifestV1 = {
|
|
||||||
id: "example.research-plugin",
|
|
||||||
apiVersion: 1,
|
|
||||||
version: "0.1.0",
|
|
||||||
displayName: "Research Plugin",
|
|
||||||
description: "Creates a managed research agent and scheduled research routine.",
|
|
||||||
author: "Example",
|
|
||||||
categories: ["automation"],
|
|
||||||
capabilities: [
|
|
||||||
"agents.managed",
|
|
||||||
"projects.managed",
|
|
||||||
"routines.managed",
|
|
||||||
"instance.settings.register",
|
|
||||||
],
|
|
||||||
entrypoints: {
|
|
||||||
worker: "./dist/worker.js",
|
|
||||||
ui: "./dist/ui",
|
|
||||||
},
|
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
agentKey: "researcher",
|
|
||||||
displayName: "Researcher",
|
|
||||||
role: "research",
|
|
||||||
title: "Research Agent",
|
|
||||||
capabilities: "Runs recurring research briefs for this company.",
|
|
||||||
adapterPreference: ["codex_local", "claude_local", "process"],
|
|
||||||
instructions: {
|
|
||||||
content: "Follow the Paperclip heartbeat and produce concise research briefs.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
projectKey: "research",
|
|
||||||
displayName: "Research",
|
|
||||||
description: "Recurring research work created by the Research Plugin.",
|
|
||||||
status: "in_progress",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
routines: [
|
|
||||||
{
|
|
||||||
routineKey: "weekly-brief",
|
|
||||||
title: "Weekly research brief",
|
|
||||||
description: "Create a short research brief for the board.",
|
|
||||||
assigneeRef: { resourceKind: "agent", resourceKey: "researcher" },
|
|
||||||
projectRef: { resourceKind: "project", resourceKey: "research" },
|
|
||||||
priority: "medium",
|
|
||||||
triggers: [
|
|
||||||
{
|
|
||||||
kind: "schedule",
|
|
||||||
label: "Monday morning",
|
|
||||||
cronExpression: "0 9 * * 1",
|
|
||||||
timezone: "America/Chicago",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
ui: {
|
|
||||||
slots: [
|
|
||||||
{
|
|
||||||
type: "settingsPage",
|
|
||||||
id: "settings",
|
|
||||||
displayName: "Research",
|
|
||||||
exportName: "SettingsPage",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default manifest;
|
|
||||||
```
|
|
||||||
|
|
||||||
In the worker, expose a small setup action or settings-page action that
|
|
||||||
reconciles the resources for the selected company:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
|
||||||
|
|
||||||
export default definePlugin({
|
|
||||||
setup(ctx) {
|
|
||||||
ctx.actions.register("setup-company", async (params) => {
|
|
||||||
const companyId = String(params.companyId ?? "");
|
|
||||||
if (!companyId) throw new Error("companyId is required");
|
|
||||||
|
|
||||||
const project = await ctx.projects.managed.reconcile("research", companyId);
|
|
||||||
const agent = await ctx.agents.managed.reconcile("researcher", companyId);
|
|
||||||
const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId);
|
|
||||||
|
|
||||||
return { project, agent, routine };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Authoring rules:
|
|
||||||
|
|
||||||
- Keep keys stable once published. Renaming `agentKey`, `projectKey`, or
|
|
||||||
`routineKey` 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 projects to keep plugin-generated work organized and to give
|
|
||||||
project-scoped plugin UI a stable home. For filesystem access inside a
|
|
||||||
project, still resolve project workspaces through `ctx.projects`.
|
|
||||||
- Keep defaults conservative. Managed declarations are suggestions owned by the
|
|
||||||
plugin, but the resulting resources are normal Paperclip records that the
|
|
||||||
operator can inspect, pause, and adjust.
|
|
||||||
|
|
||||||
UI:
|
UI:
|
||||||
|
|
||||||
- `usePluginData`
|
- `usePluginData`
|
||||||
@@ -310,187 +114,6 @@ Mount surfaces currently wired in the host include:
|
|||||||
- `commentAnnotation`
|
- `commentAnnotation`
|
||||||
- `commentContextMenuItem`
|
- `commentContextMenuItem`
|
||||||
|
|
||||||
## Shared host components
|
|
||||||
|
|
||||||
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
|
|
||||||
Paperclip-native control. The host owns the implementation, so plugins inherit
|
|
||||||
the board's current styling, ordering, recent selections, and dark-mode behavior
|
|
||||||
without importing `ui/src` internals.
|
|
||||||
|
|
||||||
Currently exposed components include:
|
|
||||||
|
|
||||||
- `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown.
|
|
||||||
- `FileTree` for serializable file and directory trees.
|
|
||||||
- `IssuesList` for a native company-scoped issue table.
|
|
||||||
- `AssigneePicker` for the same agent/user selector used in the new issue pane.
|
|
||||||
Use the controlled `value` format `agent:<id>`, `user:<id>`, or `""`.
|
|
||||||
- `ProjectPicker` for the same project selector used in the new issue pane.
|
|
||||||
Use the controlled project id value, or `""` for no project.
|
|
||||||
- `ManagedRoutinesList` for plugin-owned routine settings pages.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
|
||||||
|
|
||||||
export function PluginAssignmentControls({ companyId }: { companyId: string }) {
|
|
||||||
const [assignee, setAssignee] = useState("");
|
|
||||||
const [projectId, setProjectId] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AssigneePicker
|
|
||||||
companyId={companyId}
|
|
||||||
value={assignee}
|
|
||||||
onChange={(value) => setAssignee(value)}
|
|
||||||
/>
|
|
||||||
<ProjectPicker
|
|
||||||
companyId={companyId}
|
|
||||||
value={projectId}
|
|
||||||
onChange={setProjectId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## File and path UI
|
|
||||||
|
|
||||||
Plugin UI often needs to render a file tree, accept a folder path, or browse a
|
|
||||||
project workspace. There are three different surfaces for that, and they map to
|
|
||||||
different trust and data-flow boundaries. Pick the surface that matches the
|
|
||||||
data the plugin actually has.
|
|
||||||
|
|
||||||
### When to use the shared `FileTree`
|
|
||||||
|
|
||||||
Use `FileTree` from `@paperclipai/plugin-sdk/ui` whenever the plugin only needs
|
|
||||||
to render a serializable file/directory list and react to selection or
|
|
||||||
expand/collapse. The host owns the implementation, so plugin UI inherits the
|
|
||||||
board's icons, indent, focus ring, and dark-mode styling without importing host
|
|
||||||
internals.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {
|
|
||||||
FileTree,
|
|
||||||
type FileTreeNode,
|
|
||||||
} from "@paperclipai/plugin-sdk/ui";
|
|
||||||
|
|
||||||
const nodes: FileTreeNode[] = [
|
|
||||||
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
|
|
||||||
{
|
|
||||||
name: "wiki",
|
|
||||||
path: "wiki",
|
|
||||||
kind: "dir",
|
|
||||||
children: [
|
|
||||||
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function WikiTree() {
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["wiki"]));
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileTree
|
|
||||||
nodes={nodes}
|
|
||||||
selectedFile={selected}
|
|
||||||
expandedPaths={expanded}
|
|
||||||
onSelectFile={(path) => setSelected(path)}
|
|
||||||
onToggleDir={(path) =>
|
|
||||||
setExpanded((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.has(path) ? next.delete(path) : next.add(path);
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Good fits:
|
|
||||||
|
|
||||||
- LLM Wiki page navigation in `packages/plugins/plugin-llm-wiki` builds a
|
|
||||||
`FileTreeNode[]` from worker query results and renders it through `FileTree`.
|
|
||||||
- The example `plugin-file-browser-example` lazily fetches a directory's
|
|
||||||
children through a `loadFileList` action when `onToggleDir` fires, then
|
|
||||||
merges the children into the local tree state — letting the shared component
|
|
||||||
handle rendering and selection.
|
|
||||||
|
|
||||||
Boundary rules:
|
|
||||||
|
|
||||||
- Keep the prop surface serializable (`nodes`, `expandedPaths`, `checkedPaths`,
|
|
||||||
`fileBadges`, `fileTones`). Do not pass arbitrary render functions across the
|
|
||||||
plugin/host boundary in v1; the supported escape hatches are
|
|
||||||
`fileBadges` (status pill keyed by path) and `fileTones` (row tone keyed by
|
|
||||||
path).
|
|
||||||
- Do not import the host's `FileTree.tsx` or any `ui/src/*` module. The SDK
|
|
||||||
declaration is the only supported import path for plugin UI.
|
|
||||||
- The shared `FileTree` is for rendering and selection. Plugin-specific editors,
|
|
||||||
ingest flows, query forms, and lint runs stay inside the plugin and do not
|
|
||||||
belong as `FileTree` props.
|
|
||||||
|
|
||||||
### When to declare `localFolders`
|
|
||||||
|
|
||||||
When the plugin needs operator-configured filesystem roots — typically for
|
|
||||||
trusted local plugins like wiki tooling — declare `localFolders[]` on the
|
|
||||||
manifest and add the `local.folders` capability. The host renders a settings
|
|
||||||
surface for the operator to set the absolute path, validates the path
|
|
||||||
server-side (containment, symlinks, required files/directories), and exposes
|
|
||||||
`ctx.localFolders.readText()` and `ctx.localFolders.writeTextAtomic()` in the
|
|
||||||
worker.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const manifest = {
|
|
||||||
capabilities: ["local.folders"],
|
|
||||||
localFolders: [
|
|
||||||
{
|
|
||||||
folderKey: "content-root",
|
|
||||||
displayName: "Content root",
|
|
||||||
access: "readWrite",
|
|
||||||
requiredDirectories: ["sources", "pages"],
|
|
||||||
requiredFiles: ["schema.md"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Use this when:
|
|
||||||
|
|
||||||
- The data lives outside any project workspace.
|
|
||||||
- Reads and writes need company-scoped configuration.
|
|
||||||
- The operator picks the path once in plugin settings and the worker resolves
|
|
||||||
files relative to that root.
|
|
||||||
|
|
||||||
Do not use `localFolders` to grant the UI direct browser-side access to the
|
|
||||||
filesystem — there is no such capability. The browser still goes through the
|
|
||||||
worker via `getData` / `performAction`, and the worker only exposes paths it
|
|
||||||
chose to expose.
|
|
||||||
|
|
||||||
### When to keep worker-mediated project workspace browsing
|
|
||||||
|
|
||||||
When the data lives inside an existing project workspace, keep the browsing
|
|
||||||
flow worker-mediated:
|
|
||||||
|
|
||||||
- The worker uses `ctx.projects.listWorkspaces()` to resolve the workspace
|
|
||||||
path, then reads its filesystem with normal Node APIs.
|
|
||||||
- The plugin UI calls a `getData` handler for the root listing and an action
|
|
||||||
for lazy children, then renders them through `FileTree`.
|
|
||||||
- The worker is the only side that touches the disk. The browser receives a
|
|
||||||
serializable tree and never sees raw absolute paths it can replay.
|
|
||||||
|
|
||||||
The example `plugin-file-browser-example` is the reference for this pattern:
|
|
||||||
the worker registers `fileList` (data) and `loadFileList` (action) over the
|
|
||||||
same handler, and the UI uses the action for on-toggle directory loading so the
|
|
||||||
shared `FileTree` stays the rendering surface.
|
|
||||||
|
|
||||||
### Mixing surfaces
|
|
||||||
|
|
||||||
A single plugin can use more than one of these. The LLM Wiki uses
|
|
||||||
`localFolders` for its content root, then renders the resulting page list
|
|
||||||
through `FileTree`. The file browser example uses `ctx.projects.listWorkspaces`
|
|
||||||
to pick a workspace and renders its on-disk tree through `FileTree` with lazy
|
|
||||||
loading. Pick the boundary per data source, not per plugin.
|
|
||||||
|
|
||||||
## Company routes
|
## Company routes
|
||||||
|
|
||||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ Current limitations to keep in mind:
|
|||||||
- Published npm packages are the intended install artifact for deployed plugins.
|
- Published npm packages are the intended install artifact for deployed plugins.
|
||||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||||
- The current runtime ships a small host-provided plugin UI component kit through `@paperclipai/plugin-sdk/ui`, but does not support plugin asset uploads/reads yet. Treat plugin asset APIs as future-scope ideas, not current implementation promises.
|
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||||
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
|
|
||||||
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
|
|
||||||
API routes.
|
|
||||||
|
|
||||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||||
|
|
||||||
@@ -627,46 +624,7 @@ Required SDK clients:
|
|||||||
|
|
||||||
Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations.
|
Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations.
|
||||||
|
|
||||||
## 14.1 Issue Orchestration APIs
|
## 14.1 Example SDK Shape
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Origin rules:
|
|
||||||
|
|
||||||
- Built-in core issues keep built-in origins such as `manual` and `routine_execution`.
|
|
||||||
- Plugin-managed issues use `plugin:<pluginKey>` or a sub-kind such as `plugin:<pluginKey>:feature`.
|
|
||||||
- The host derives the default plugin origin from the installed plugin key and rejects attempts to set `plugin:<otherPluginKey>` origins.
|
|
||||||
- `originId` is plugin-defined and should be stable for idempotent generated work.
|
|
||||||
|
|
||||||
Relation and read helpers:
|
|
||||||
|
|
||||||
- `ctx.issues.relations.get(issueId, companyId)`
|
|
||||||
- `ctx.issues.relations.setBlockedBy(issueId, blockerIssueIds, companyId)`
|
|
||||||
- `ctx.issues.relations.addBlockers(issueId, blockerIssueIds, companyId)`
|
|
||||||
- `ctx.issues.relations.removeBlockers(issueId, blockerIssueIds, companyId)`
|
|
||||||
- `ctx.issues.getSubtree(issueId, companyId, options)`
|
|
||||||
- `ctx.issues.summaries.getOrchestration({ issueId, companyId, includeSubtree, billingCode })`
|
|
||||||
|
|
||||||
Governance helpers:
|
|
||||||
|
|
||||||
- `ctx.issues.assertCheckoutOwner({ issueId, companyId, actorAgentId, actorRunId })` lets plugin actions preserve agent-run checkout ownership.
|
|
||||||
- `ctx.issues.requestWakeup(issueId, companyId, options)` requests assignment wakeups through host heartbeat semantics, including terminal-status, blocker, assignee, and budget hard-stop checks.
|
|
||||||
- `ctx.issues.requestWakeups(issueIds, companyId, options)` applies the same host-owned wakeup semantics to a batch and may use an idempotency key prefix for stable coordinator retries.
|
|
||||||
|
|
||||||
Plugin-originated issue, relation, document, comment, and wakeup mutations must write activity entries with `actorType: "plugin"` and details fields for `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work.
|
|
||||||
|
|
||||||
Scoped API routes:
|
|
||||||
|
|
||||||
- `apiRoutes[]` declares `routeKey`, `method`, plugin-local `path`, `auth`,
|
|
||||||
`capability`, optional checkout policy, and company resolution.
|
|
||||||
- The host enforces auth, company access, `api.routes.register`, route matching,
|
|
||||||
and checkout policy before worker dispatch.
|
|
||||||
- The worker implements `onApiRequest(input)` and returns a JSON response shape
|
|
||||||
`{ status?, headers?, body? }`.
|
|
||||||
- Only safe request headers are forwarded; auth/cookie headers are never passed
|
|
||||||
to the worker.
|
|
||||||
|
|
||||||
## 14.2 Example SDK Shape
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
/** Top-level helper for defining a plugin with type checking */
|
/** Top-level helper for defining a plugin with type checking */
|
||||||
@@ -738,24 +696,16 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr
|
|||||||
- `project.workspaces.read`
|
- `project.workspaces.read`
|
||||||
- `issues.read`
|
- `issues.read`
|
||||||
- `issue.comments.read`
|
- `issue.comments.read`
|
||||||
- `issue.documents.read`
|
|
||||||
- `issue.relations.read`
|
|
||||||
- `issue.subtree.read`
|
|
||||||
- `agents.read`
|
- `agents.read`
|
||||||
- `goals.read`
|
- `goals.read`
|
||||||
- `activity.read`
|
- `activity.read`
|
||||||
- `costs.read`
|
- `costs.read`
|
||||||
- `issues.orchestration.read`
|
|
||||||
|
|
||||||
### Data Write
|
### Data Write
|
||||||
|
|
||||||
- `issues.create`
|
- `issues.create`
|
||||||
- `issues.update`
|
- `issues.update`
|
||||||
- `issue.comments.create`
|
- `issue.comments.create`
|
||||||
- `issue.documents.write`
|
|
||||||
- `issue.relations.write`
|
|
||||||
- `issues.checkout`
|
|
||||||
- `issues.wakeup`
|
|
||||||
- `assets.write`
|
- `assets.write`
|
||||||
- `assets.read`
|
- `assets.read`
|
||||||
- `activity.log.write`
|
- `activity.log.write`
|
||||||
@@ -822,13 +772,6 @@ Minimum event set:
|
|||||||
- `issue.created`
|
- `issue.created`
|
||||||
- `issue.updated`
|
- `issue.updated`
|
||||||
- `issue.comment.created`
|
- `issue.comment.created`
|
||||||
- `issue.document.created`
|
|
||||||
- `issue.document.updated`
|
|
||||||
- `issue.document.deleted`
|
|
||||||
- `issue.relations.updated`
|
|
||||||
- `issue.checked_out`
|
|
||||||
- `issue.released`
|
|
||||||
- `issue.assignment_wakeup_requested`
|
|
||||||
- `agent.created`
|
- `agent.created`
|
||||||
- `agent.updated`
|
- `agent.updated`
|
||||||
- `agent.status_changed`
|
- `agent.status_changed`
|
||||||
@@ -838,8 +781,6 @@ Minimum event set:
|
|||||||
- `agent.run.cancelled`
|
- `agent.run.cancelled`
|
||||||
- `approval.created`
|
- `approval.created`
|
||||||
- `approval.decided`
|
- `approval.decided`
|
||||||
- `budget.incident.opened`
|
|
||||||
- `budget.incident.resolved`
|
|
||||||
- `cost_event.created`
|
- `cost_event.created`
|
||||||
- `activity.logged`
|
- `activity.logged`
|
||||||
|
|
||||||
@@ -976,23 +917,13 @@ export function DashboardWidget({ context }: PluginWidgetProps) {
|
|||||||
|
|
||||||
The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides:
|
The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides:
|
||||||
|
|
||||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`, `useHostNavigation()`
|
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`
|
||||||
- **Design tokens**: colors, spacing, typography, shadows matching the host theme
|
- **Design tokens**: colors, spacing, typography, shadows matching the host theme
|
||||||
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
|
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
|
||||||
- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps`
|
- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps`
|
||||||
|
|
||||||
Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
|
Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
|
||||||
|
|
||||||
`useHostNavigation()` is the supported way for plugin UI to navigate to
|
|
||||||
Paperclip-internal pages. It exposes `resolveHref(to)`, `navigate(to,
|
|
||||||
options?)`, and `linkProps(to, options?)`. Plugin links should prefer
|
|
||||||
`linkProps()` so anchors keep real `href` values for copy-link, modifier-click,
|
|
||||||
middle-click, and open-in-new-tab behavior while plain left-clicks route through
|
|
||||||
the host SPA router. The host resolves company-scoped paths against the active
|
|
||||||
company prefix without double-prefixing already-prefixed paths. Plugin UI should
|
|
||||||
not use raw same-origin `href`s or `window.location.assign()` for internal
|
|
||||||
Paperclip navigation because those can force a full document reload.
|
|
||||||
|
|
||||||
### 19.0.2 Bundle Isolation
|
### 19.0.2 Bundle Isolation
|
||||||
|
|
||||||
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
|
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
|
||||||
@@ -1072,11 +1003,6 @@ The host SDK ships shared components that plugins can import to quickly build UI
|
|||||||
| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
|
| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
|
||||||
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
|
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
|
||||||
| `Spinner` | Loading indicator | Data fetch states |
|
| `Spinner` | Loading indicator | Data fetch states |
|
||||||
| `FileTree` | Host-styled file/directory tree | Wiki pages, workspace files, import previews |
|
|
||||||
| `IssuesList` | Host issue list | Plugin pages that need a native issue view |
|
|
||||||
| `AssigneePicker` | Host assignee picker for agents and board users | Creating issues, assigning routines, filtering work |
|
|
||||||
| `ProjectPicker` | Host project picker | Creating issues, scoping dashboards, filtering work |
|
|
||||||
| `ManagedRoutinesList` | Host routine list | Plugin settings pages that manage routines |
|
|
||||||
|
|
||||||
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
|
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
|
||||||
|
|
||||||
@@ -1312,8 +1238,6 @@ Plugin-originated mutations should write:
|
|||||||
|
|
||||||
- `actor_type = plugin`
|
- `actor_type = plugin`
|
||||||
- `actor_id = <plugin-id>`
|
- `actor_id = <plugin-id>`
|
||||||
- details include `sourcePluginId` and `sourcePluginKey`
|
|
||||||
- details include `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run triggered the plugin work
|
|
||||||
|
|
||||||
## 21.5 Plugin Migrations
|
## 21.5 Plugin Migrations
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -114,14 +114,14 @@ If the connection drops, the UI reconnects automatically.
|
|||||||
|
|
||||||
1. Enable timer wakeups (for example every 300s)
|
1. Enable timer wakeups (for example every 300s)
|
||||||
2. Keep assignment wakeups on
|
2. Keep assignment wakeups on
|
||||||
3. Use a focused prompt template that tells agents to act in the same heartbeat, leave durable progress, and mark blocked work with an owner/action
|
3. Use a focused prompt template
|
||||||
4. Watch run logs and adjust prompt/config over time
|
4. Watch run logs and adjust prompt/config over time
|
||||||
|
|
||||||
## 7.2 Event-driven loop (less constant polling)
|
## 7.2 Event-driven loop (less constant polling)
|
||||||
|
|
||||||
1. Disable timer or set a long interval
|
1. Disable timer or set a long interval
|
||||||
2. Keep wake-on-assignment enabled
|
2. Keep wake-on-assignment enabled
|
||||||
3. Use child issues, comments, and on-demand wakeups for handoffs instead of loops that poll agents, sessions, or processes
|
3. Use on-demand wakeups for manual nudges
|
||||||
|
|
||||||
## 7.3 Safety-first loop
|
## 7.3 Safety-first loop
|
||||||
|
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
# Invite Flow State Map
|
|
||||||
|
|
||||||
Status: Current implementation map
|
|
||||||
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/pages/InviteLanding.tsx`
|
|
||||||
- `server/src/routes/access.ts`
|
|
||||||
- `server/src/lib/join-request-dedupe.ts`
|
|
||||||
|
|
||||||
## State Legend
|
|
||||||
|
|
||||||
- Invite state: `active`, `revoked`, `accepted`, `expired`
|
|
||||||
- Join request status: `pending_approval`, `approved`, `rejected`
|
|
||||||
- Claim secret state for agent joins: `available`, `consumed`, `expired`
|
|
||||||
- Invite type: `company_join` or `bootstrap_ceo`
|
|
||||||
- Join type: `human`, `agent`, or `both`
|
|
||||||
|
|
||||||
## Entity Lifecycle
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
Board[Board user on invite screen]
|
|
||||||
HumanInvite[Create human company invite]
|
|
||||||
OpenClawInvite[Generate OpenClaw invite prompt]
|
|
||||||
Active[Invite state: active]
|
|
||||||
Revoked[Invite state: revoked]
|
|
||||||
Expired[Invite state: expired]
|
|
||||||
Accepted[Invite state: accepted]
|
|
||||||
BootstrapDone[Bootstrap accepted<br/>no join request]
|
|
||||||
HumanReuse{Matching human join request<br/>already exists for same user/email?}
|
|
||||||
HumanPending[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]
|
|
||||||
AgentApproved[Agent join request<br/>approved]
|
|
||||||
AgentRejected[Agent join request<br/>rejected]
|
|
||||||
ClaimAvailable[Claim secret available]
|
|
||||||
ClaimConsumed[Claim secret consumed]
|
|
||||||
ClaimExpired[Claim secret expired]
|
|
||||||
OpenClawReplay[Special replay path:<br/>accepted invite can be POSTed again<br/>for openclaw_gateway only]
|
|
||||||
|
|
||||||
Board --> HumanInvite --> Active
|
|
||||||
Board --> OpenClawInvite --> Active
|
|
||||||
Active --> Revoked: revoke
|
|
||||||
Active --> Expired: expiresAt passes
|
|
||||||
|
|
||||||
Active --> BootstrapDone: bootstrap_ceo accept
|
|
||||||
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
|
|
||||||
HumanPending --> HumanRejected: board rejects
|
|
||||||
HumanPending --> Accepted
|
|
||||||
HumanApproved --> Accepted
|
|
||||||
|
|
||||||
Active --> AgentPending: agent accept
|
|
||||||
AgentPending --> Accepted
|
|
||||||
AgentPending --> AgentApproved: board approves
|
|
||||||
AgentPending --> AgentRejected: board rejects
|
|
||||||
AgentApproved --> ClaimAvailable: createdAgentId + claimSecretHash
|
|
||||||
ClaimAvailable --> ClaimConsumed: POST claim-api-key succeeds
|
|
||||||
ClaimAvailable --> ClaimExpired: secret expires
|
|
||||||
|
|
||||||
Accepted --> OpenClawReplay
|
|
||||||
OpenClawReplay --> AgentPending
|
|
||||||
OpenClawReplay --> AgentApproved
|
|
||||||
```
|
|
||||||
|
|
||||||
## Board-Side Screen States
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> CompanySelection
|
|
||||||
|
|
||||||
CompanySelection --> NoCompany: no company selected
|
|
||||||
CompanySelection --> LoadingHistory: selectedCompanyId present
|
|
||||||
LoadingHistory --> HistoryError: listInvites failed
|
|
||||||
LoadingHistory --> Ready: listInvites succeeded
|
|
||||||
|
|
||||||
state Ready {
|
|
||||||
[*] --> EmptyHistory
|
|
||||||
EmptyHistory --> PopulatedHistory: invites exist
|
|
||||||
PopulatedHistory --> LoadingMore: View more
|
|
||||||
LoadingMore --> PopulatedHistory: next page loaded
|
|
||||||
|
|
||||||
PopulatedHistory --> RevokePending: Revoke active invite
|
|
||||||
RevokePending --> PopulatedHistory: revoke succeeded
|
|
||||||
RevokePending --> PopulatedHistory: revoke failed
|
|
||||||
|
|
||||||
EmptyHistory --> CreatePending: Create invite
|
|
||||||
PopulatedHistory --> CreatePending: Create invite
|
|
||||||
CreatePending --> LatestInviteVisible: create succeeded
|
|
||||||
CreatePending --> Ready: create failed
|
|
||||||
LatestInviteVisible --> CopiedToast: clipboard copy succeeded
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Invite Landing Screen States
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> TokenGate
|
|
||||||
|
|
||||||
TokenGate --> InvalidToken: token missing
|
|
||||||
TokenGate --> Loading: token present
|
|
||||||
Loading --> InviteUnavailable: invite fetch failed or invite not returned
|
|
||||||
Loading --> CheckingAccess: signed-in session and invite.companyId
|
|
||||||
Loading --> InviteResolved: invite loaded without membership check
|
|
||||||
Loading --> AcceptedInviteSummary: invite already consumed<br/>but linked join request still exists
|
|
||||||
|
|
||||||
CheckingAccess --> RedirectToBoard: current user already belongs to company
|
|
||||||
CheckingAccess --> InviteResolved: membership check finished and no join-request summary state is active
|
|
||||||
CheckingAccess --> AcceptedInviteSummary: membership check finished and invite has joinRequestStatus
|
|
||||||
|
|
||||||
state InviteResolved {
|
|
||||||
[*] --> Branch
|
|
||||||
Branch --> AgentForm: company_join + allowedJoinTypes=agent
|
|
||||||
Branch --> InlineAuth: authenticated mode + no session + join is not agent-only
|
|
||||||
Branch --> AcceptReady: bootstrap invite or human-ready session/local_trusted
|
|
||||||
|
|
||||||
InlineAuth --> InlineAuth: toggle sign-up/sign-in
|
|
||||||
InlineAuth --> InlineAuth: auth validation or auth error message
|
|
||||||
InlineAuth --> RedirectToBoard: auth succeeded and company membership already exists
|
|
||||||
InlineAuth --> AcceptPending: auth succeeded and invite still needs acceptance
|
|
||||||
|
|
||||||
AgentForm --> AcceptPending: submit request
|
|
||||||
AgentForm --> AgentForm: validation or accept error
|
|
||||||
|
|
||||||
AcceptReady --> AcceptPending: Accept invite
|
|
||||||
AcceptReady --> AcceptReady: accept error
|
|
||||||
}
|
|
||||||
|
|
||||||
AcceptPending --> BootstrapComplete: bootstrapAccepted=true
|
|
||||||
AcceptPending --> RedirectToBoard: join status=approved
|
|
||||||
AcceptPending --> PendingApprovalResult: join status=pending_approval
|
|
||||||
AcceptPending --> RejectedResult: join status=rejected
|
|
||||||
|
|
||||||
state AcceptedInviteSummary {
|
|
||||||
[*] --> SummaryBranch
|
|
||||||
SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval
|
|
||||||
SummaryBranch --> OpeningCompany: joinRequestStatus=approved<br/>and human invite user is now a member
|
|
||||||
SummaryBranch --> RejectedReload: joinRequestStatus=rejected
|
|
||||||
SummaryBranch --> ConsumedReload: approved agent invite or other consumed state
|
|
||||||
}
|
|
||||||
|
|
||||||
PendingApprovalResult --> PendingApprovalReload: reload after submit
|
|
||||||
RejectedResult --> RejectedReload: reload after board rejects
|
|
||||||
RedirectToBoard --> OpeningCompany: brief pre-navigation render when approved membership is detected
|
|
||||||
OpeningCompany --> RedirectToBoard: navigate to board
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sequence Diagrams
|
|
||||||
|
|
||||||
### Human Invite Creation And First Acceptance
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
actor Board as Board user
|
|
||||||
participant Settings as Company Invites UI
|
|
||||||
participant API as Access routes
|
|
||||||
participant Invites as invites table
|
|
||||||
actor Invitee as Invite recipient
|
|
||||||
participant Landing as Invite landing UI
|
|
||||||
participant Auth as Auth session
|
|
||||||
participant Join as join_requests table
|
|
||||||
|
|
||||||
Board->>Settings: Choose role and click Create invite
|
|
||||||
Settings->>API: POST /api/companies/:companyId/invites
|
|
||||||
API->>Invites: Insert active invite
|
|
||||||
API-->>Settings: inviteUrl + metadata
|
|
||||||
|
|
||||||
Invitee->>Landing: Open invite URL
|
|
||||||
Landing->>API: GET /api/invites/:token
|
|
||||||
API->>Invites: Load active invite
|
|
||||||
API-->>Landing: Invite summary
|
|
||||||
|
|
||||||
alt Authenticated mode and no session
|
|
||||||
Landing->>Auth: Sign up or sign in
|
|
||||||
Auth-->>Landing: Session established
|
|
||||||
end
|
|
||||||
|
|
||||||
Landing->>API: POST /api/invites/:token/accept (requestType=human)
|
|
||||||
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
|
|
||||||
else No reusable request exists
|
|
||||||
API->>Invites: Mark invite accepted
|
|
||||||
API->>Join: Insert pending_approval join request
|
|
||||||
API-->>Landing: New pending_approval join request
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Human Approval And Reload Path
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
actor Invitee as Invite recipient
|
|
||||||
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
|
|
||||||
Landing->>API: GET /api/invites/:token
|
|
||||||
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
|
|
||||||
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
|
|
||||||
Landing-->>Invitee: Fall through to consumed/unavailable state
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agent Invite Approval, Claim, And Replay
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
actor Board as Board user
|
|
||||||
participant Settings as Company Settings UI
|
|
||||||
participant API as Access routes
|
|
||||||
participant Invites as invites table
|
|
||||||
actor Gateway as OpenClaw gateway agent
|
|
||||||
participant Join as join_requests table
|
|
||||||
actor Approver as Company admin
|
|
||||||
participant Agents as agents table
|
|
||||||
participant Keys as agent_api_keys table
|
|
||||||
|
|
||||||
Board->>Settings: Generate OpenClaw invite prompt
|
|
||||||
Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt
|
|
||||||
API->>Invites: Insert active agent invite
|
|
||||||
API-->>Settings: Prompt text + invite token
|
|
||||||
|
|
||||||
Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway)
|
|
||||||
API->>Invites: Mark invite accepted
|
|
||||||
API->>Join: Insert pending_approval join request + claimSecretHash
|
|
||||||
API-->>Gateway: requestId + claimSecret + claimApiKeyPath
|
|
||||||
|
|
||||||
Approver->>API: POST /companies/:companyId/join-requests/:requestId/approve
|
|
||||||
API->>Agents: Create agent + membership + grants
|
|
||||||
API->>Join: Mark request approved and store createdAgentId
|
|
||||||
|
|
||||||
Gateway->>API: POST /api/join-requests/:requestId/claim-api-key (claimSecret)
|
|
||||||
API->>Keys: Create initial API key
|
|
||||||
API->>Join: Mark claim secret consumed
|
|
||||||
API-->>Gateway: Plaintext Paperclip API key
|
|
||||||
|
|
||||||
opt Replay accepted invite for updated gateway defaults
|
|
||||||
Gateway->>API: POST /api/invites/:token/accept again
|
|
||||||
API->>Join: Reuse existing approved or pending request
|
|
||||||
API->>Agents: Update approved agent adapter config when applicable
|
|
||||||
API-->>Gateway: Updated join request payload
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- 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
|
|
||||||
- `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
|
|
||||||
- `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`.
|
|
||||||
- `bootstrap_ceo` invites are one-time and do not create join requests.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# AWS ECS Fargate deployment environment
|
|
||||||
# Copy to .env.aws and fill in values before deploying
|
|
||||||
#
|
|
||||||
# Secrets (DATABASE_URL, BETTER_AUTH_SECRET, ANTHROPIC_API_KEY, OPENAI_API_KEY,
|
|
||||||
# GITHUB_TOKEN) are injected via AWS Secrets Manager — do NOT set them here.
|
|
||||||
|
|
||||||
# Deployment mode
|
|
||||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated
|
|
||||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=public
|
|
||||||
PAPERCLIP_PUBLIC_URL=https://paperclip.example.com
|
|
||||||
|
|
||||||
# Server
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=3100
|
|
||||||
NODE_ENV=production
|
|
||||||
SERVE_UI=true
|
|
||||||
|
|
||||||
# Paperclip paths
|
|
||||||
PAPERCLIP_HOME=/paperclip
|
|
||||||
PAPERCLIP_INSTANCE_ID=default
|
|
||||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json
|
|
||||||
|
|
||||||
# Auto-apply migrations on startup
|
|
||||||
PAPERCLIP_MIGRATION_AUTO_APPLY=true
|
|
||||||
|
|
||||||
# Enable heartbeat scheduler for remote agents
|
|
||||||
HEARTBEAT_SCHEDULER_ENABLED=true
|
|
||||||
|
|
||||||
# Post-deploy hardening (uncomment after first user signs up)
|
|
||||||
# PAPERCLIP_AUTH_DISABLE_SIGN_UP=true
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "paperclip-server",
|
|
||||||
"networkMode": "awsvpc",
|
|
||||||
"requiresCompatibilities": ["FARGATE"],
|
|
||||||
"cpu": "2048",
|
|
||||||
"memory": "4096",
|
|
||||||
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/paperclip-ecs-execution",
|
|
||||||
"taskRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/paperclip-ecs-task",
|
|
||||||
"containerDefinitions": [
|
|
||||||
{
|
|
||||||
"name": "paperclip-server",
|
|
||||||
"image": "<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/paperclip-server:latest",
|
|
||||||
"essential": true,
|
|
||||||
"portMappings": [
|
|
||||||
{
|
|
||||||
"containerPort": 3100,
|
|
||||||
"protocol": "tcp"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"environment": [
|
|
||||||
{ "name": "NODE_ENV", "value": "production" },
|
|
||||||
{ "name": "HOST", "value": "0.0.0.0" },
|
|
||||||
{ "name": "PORT", "value": "3100" },
|
|
||||||
{ "name": "SERVE_UI", "value": "true" },
|
|
||||||
{ "name": "PAPERCLIP_HOME", "value": "/paperclip" },
|
|
||||||
{ "name": "PAPERCLIP_INSTANCE_ID", "value": "default" },
|
|
||||||
{ "name": "PAPERCLIP_CONFIG", "value": "/paperclip/instances/default/config.json" },
|
|
||||||
{ "name": "PAPERCLIP_DEPLOYMENT_MODE", "value": "authenticated" },
|
|
||||||
{ "name": "PAPERCLIP_DEPLOYMENT_EXPOSURE", "value": "public" },
|
|
||||||
{ "name": "PAPERCLIP_PUBLIC_URL", "value": "https://<DOMAIN>" },
|
|
||||||
{ "name": "PAPERCLIP_MIGRATION_AUTO_APPLY", "value": "true" },
|
|
||||||
{ "name": "HEARTBEAT_SCHEDULER_ENABLED", "value": "true" }
|
|
||||||
],
|
|
||||||
"secrets": [
|
|
||||||
{
|
|
||||||
"name": "DATABASE_URL",
|
|
||||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/database-url"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BETTER_AUTH_SECRET",
|
|
||||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/better-auth-secret"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ANTHROPIC_API_KEY",
|
|
||||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/anthropic-api-key"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "OPENAI_API_KEY",
|
|
||||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/openai-api-key"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GITHUB_TOKEN",
|
|
||||||
"valueFrom": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:paperclip/github-token"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"mountPoints": [
|
|
||||||
{
|
|
||||||
"sourceVolume": "paperclip-data",
|
|
||||||
"containerPath": "/paperclip",
|
|
||||||
"readOnly": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"healthCheck": {
|
|
||||||
"command": ["CMD-SHELL", "curl -f http://localhost:3100/api/health || exit 1"],
|
|
||||||
"interval": 30,
|
|
||||||
"timeout": 5,
|
|
||||||
"retries": 3,
|
|
||||||
"startPeriod": 60
|
|
||||||
},
|
|
||||||
"logConfiguration": {
|
|
||||||
"logDriver": "awslogs",
|
|
||||||
"options": {
|
|
||||||
"awslogs-group": "/ecs/paperclip",
|
|
||||||
"awslogs-region": "<REGION>",
|
|
||||||
"awslogs-stream-prefix": "server"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"volumes": [
|
|
||||||
{
|
|
||||||
"name": "paperclip-data",
|
|
||||||
"efsVolumeConfiguration": {
|
|
||||||
"fileSystemId": "<EFS_ID>",
|
|
||||||
"rootDirectory": "/",
|
|
||||||
"transitEncryption": "ENABLED"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p
|
|||||||
| `env` | object | No | Environment variables (supports secret refs) |
|
| `env` | object | No | Environment variables (supports secret refs) |
|
||||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||||
| `graceSec` | number | No | Grace period before force-kill |
|
| `graceSec` | number | No | Grace period before force-kill |
|
||||||
| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster |
|
|
||||||
| `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) |
|
| `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) |
|
||||||
|
|
||||||
## Session Persistence
|
## Session Persistence
|
||||||
@@ -31,22 +30,8 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
|
|||||||
|
|
||||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||||
|
|
||||||
## Fast Mode
|
|
||||||
|
|
||||||
When `fastMode` is enabled, Paperclip adds Codex config overrides equivalent to:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
-c 'service_tier="fast"' -c 'features.fast_mode=true'
|
|
||||||
```
|
|
||||||
|
|
||||||
Paperclip currently applies that only when the selected model is `gpt-5.4`. On other models, the toggle is preserved in config but ignored at execution time to avoid unsupported runs.
|
|
||||||
|
|
||||||
## Managed `CODEX_HOME`
|
|
||||||
|
|
||||||
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
|
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
|
||||||
|
|
||||||
## Manual Local CLI
|
|
||||||
|
|
||||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -203,43 +203,6 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## Capability Flags
|
|
||||||
|
|
||||||
Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.).
|
|
||||||
|
|
||||||
| Flag | Type | Default | What it controls |
|
|
||||||
|------|------|---------|------------------|
|
|
||||||
| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent |
|
|
||||||
| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor |
|
|
||||||
| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path |
|
|
||||||
| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution |
|
|
||||||
|
|
||||||
These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined).
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export function createServerAdapter(): ServerAdapterModule {
|
|
||||||
return {
|
|
||||||
type: "my_k8s_adapter",
|
|
||||||
execute: myExecute,
|
|
||||||
testEnvironment: myTestEnvironment,
|
|
||||||
listSkills: myListSkills,
|
|
||||||
syncSkills: mySyncSkills,
|
|
||||||
|
|
||||||
// Capability flags
|
|
||||||
supportsLocalAgentJwt: true,
|
|
||||||
supportsInstructionsBundle: true,
|
|
||||||
instructionsPathKey: "instructionsFilePath",
|
|
||||||
requiresMaterializedRuntimeSkills: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required.
|
|
||||||
|
|
||||||
If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities.
|
|
||||||
|
|
||||||
## Skills Injection
|
## Skills Injection
|
||||||
|
|
||||||
Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory:
|
Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory:
|
||||||
|
|||||||
@@ -124,14 +124,14 @@ If the connection drops, the UI reconnects automatically.
|
|||||||
|
|
||||||
1. Enable timer wakeups (for example every 300s)
|
1. Enable timer wakeups (for example every 300s)
|
||||||
2. Keep assignment wakeups on
|
2. Keep assignment wakeups on
|
||||||
3. Use a focused prompt template that tells agents to act in the same heartbeat, leave durable progress, and mark blocked work with an owner/action
|
3. Use a focused prompt template
|
||||||
4. Watch run logs and adjust prompt/config over time
|
4. Watch run logs and adjust prompt/config over time
|
||||||
|
|
||||||
## 7.2 Event-driven loop (less constant polling)
|
## 7.2 Event-driven loop (less constant polling)
|
||||||
|
|
||||||
1. Disable timer or set a long interval
|
1. Disable timer or set a long interval
|
||||||
2. Keep wake-on-assignment enabled
|
2. Keep wake-on-assignment enabled
|
||||||
3. Use child issues, comments, and on-demand wakeups for handoffs instead of loops that poll agents, sessions, or processes
|
3. Use on-demand wakeups for manual nudges
|
||||||
|
|
||||||
## 7.3 Safety-first loop
|
## 7.3 Safety-first loop
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ GET /api/companies/{companyId}/agents
|
|||||||
|
|
||||||
Returns all agents in the company.
|
Returns all agents in the company.
|
||||||
|
|
||||||
This route does not accept query filters. Unsupported query parameters return `400`.
|
|
||||||
|
|
||||||
## Get Agent
|
## Get Agent
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||