Compare commits

...

13 Commits

Author SHA1 Message Date
Chris Farhood 7e2517935c feat(env): collapse env value textarea to single line when unfocused 2026-05-02 07:36:11 -04:00
Chris Farhood 441bbd5b9a feat(env): allow multi-line env var values via auto-growing textarea
Replace the plain-value <input> with the shadcn Textarea (which has
field-sizing: content baked in). Starts at rows={1}, grows as content
needs more vertical space. Storage and runtime already preserve newlines
so this is purely a UI capability change.
2026-05-02 07:15:08 -04:00
Chris Farhood bf1abb1492 chore(plugin-rpc): raise MAX_RPC_TIMEOUT_MS cap to 60 minutes 2026-05-01 21:00:12 -04:00
Chris Farhood 1678160c49 fix(ci): add acpx-local + sandbox plugins to fork Dockerfile deps stage 2026-05-01 19:31:44 -04:00
Chris Farhood c08c72e917 chore(ci): restore fork CI overlay 2026-05-01 19:27:04 -04:00
Chris Farhood fe43fbe2fd Merge branches 'feat/skills-gitops-complete', 'feat/company-portability-complete', 'feat/board-approval-markdown' and 'fix/remove-paperclip-dev-skill' into local 2026-05-01 19:26:56 -04:00
Chris Farhood 6bbe51ca4d fix(skills): remove bundled paperclip-dev skill
The bundled paperclip-dev skill was force-flagged as required in
listRuntimeSkillEntries (sourceKind === "paperclip_bundled" overrode
the SKILL.md frontmatter), so the per-agent toggle was always disabled.
Drop the skill outright on this fork — we don't ship it.
2026-05-01 12:10:48 -04:00
Chris Farhood 2131ede7b8 feat(board): render approval summary/recommendedAction/nextActionOnApproval as markdown
Replaces plain <p> tags in BoardApprovalPayloadContent with MarkdownBody
(softBreaks enabled) so agent-authored markdown in these three fields —
headers, bullets, and newlines — renders correctly in the Board UI instead
of collapsing into a single unstyled paragraph.  No schema change; the
fields remain plain strings in the approval payload, only the renderer
changed.  Matches how comments, issue documents, and interaction cards
already render markdown via MarkdownBody.

Test coverage added for ## header → <h2>, bullet list → <ul><li>, and
plain-prose regression (no markup injected for single-line inputs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:20:14 -04:00
Chris Farhood e8579d5c66 feat(import-export): complete company portability — secrets export/import and env round-tripping
Adds opt-in secret export/import: secret values are resolved (and optionally
decrypted) into the portability manifest, and re-created with conflict
handling on import. Fixes env round-tripping so both secret_ref and plain
bindings survive export/import cycles.
2026-05-01 08:18:54 -04:00
Chris Farhood 9e30b72b27 test(skills): cover PAT import, skill auth, and scan-projects routes
- importFromSource is invoked with the PAT when one is supplied in the body
- PATCH /skills/:id/auth updates and clears tokens, with matching activity log entries
- POST /skills/scan-projects is reachable to agents that hold canCreateAgents
- Update existing import-permission assertion to include the new authToken arg
2026-05-01 07:58:51 -04:00
Chris Farhood 7b12d907cc feat(skills): scan re-scans existing GitHub/sks_sh sources for new skills
When the project workspace scan runs, also iterate the source locators of
all accepted GitHub and sks_sh skills, re-fetch each source, and upsert any
skills that have appeared since the last import. Per-source failures are
collected as warnings instead of aborting the whole scan.
2026-05-01 07:43:02 -04:00
Chris Farhood d1d592d793 fix(security): use manual redirects when PAT is attached
Token-free requests follow redirects normally to support renamed/transferred
GitHub repos. Manual redirect policy is only needed when a PAT is attached,
to prevent the bearer token from being forwarded to attacker-controlled
redirect targets.
2026-05-01 07:41:57 -04:00
Chris Farhood 3dfb859676 feat(skills): GitHub PAT support for private skill repos
- Add optional authToken to skill import for GitHub private repos
- Store PAT as encrypted company secret (skill-pat:{skillId})
- Thread auth token through ghFetch and GitHub resolution helpers
- Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill
- Preserve sourceAuthSecretId across skill re-imports/updates
- Delete PAT secret on PAT clear and on skill deletion to prevent orphans
- UI: Add PAT input field in import form for GitHub URLs
- UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT
2026-05-01 07:41:48 -04:00
32 changed files with 2016 additions and 642 deletions
+93
View File
@@ -0,0 +1,93 @@
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
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\"}"
+53
View File
@@ -0,0 +1,53 @@
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
+80
View File
@@ -0,0 +1,80 @@
# 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`, `fix/far-108-k8s-adapter-reaper-liveness` — 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 |
| #4162 | `fix/far-108-k8s-adapter-reaper-liveness` | cherry-pick | K8s adapter reaper liveness |
## 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
+98
View File
@@ -0,0 +1,98 @@
# 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-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, 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 \
&& 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"]
+193
View File
@@ -0,0 +1,193 @@
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"
+93
View File
@@ -0,0 +1,93 @@
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
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\"}"
+53
View File
@@ -0,0 +1,53 @@
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
+14 -50
View File
@@ -1,55 +1,19 @@
# 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:
push:
branches:
- "master"
tags:
- "v*"
permissions:
contents: read
packages: write
workflow_dispatch:
inputs:
note:
description: "Disabled in fork. Use build-prod.yml (local) or build-dev.yml (dev)."
required: false
jobs:
build-and-push:
disabled:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- run: echo "Disabled. See build-prod.yml and build-dev.yml."
+8 -253
View File
@@ -1,261 +1,16 @@
# 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:
push:
branches:
- master
workflow_dispatch:
inputs:
source_ref:
description: Commit SHA, branch, or tag to publish as stable
required: true
type: string
default: master
stable_date:
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
note:
description: "Disabled in fork."
required: false
type: string
dry_run:
description: Preview the stable release without publishing
required: true
type: boolean
default: false
concurrency:
group: release-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify_canary:
if: github.event_name == 'push'
disabled:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- 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 --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
publish_canary:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-canary
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- 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 --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish canary
env:
GITHUB_ACTIONS: "true"
run: ./scripts/release.sh canary --skip-verify
- name: Push canary tag
run: |
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no canary tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
verify_stable:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- 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 --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
preview_stable:
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- 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 --no-frozen-lockfile
- name: Dry-run stable release
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify --dry-run)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
publish_stable:
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-stable
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- 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 --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_REMOTE: origin
run: |
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
if [ -z "$version" ]; then
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"
- run: echo "Disabled in fork."
+70
View File
@@ -0,0 +1,70 @@
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
+80
View File
@@ -0,0 +1,80 @@
# 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`, `fix/far-108-k8s-adapter-reaper-liveness` — 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 |
| #4162 | `fix/far-108-k8s-adapter-reaper-liveness` | cherry-pick | K8s adapter reaper liveness |
## 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
+2
View File
@@ -476,6 +476,7 @@ export type {
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
CompanyPortabilitySecretEntry,
EnvBinding,
AgentEnvConfig,
CompanySecret,
@@ -817,6 +818,7 @@ export {
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -1,4 +1,4 @@
import type { AgentEnvConfig } from "./secrets.js";
import type { AgentEnvConfig, SecretProvider } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityInclude {
@@ -18,6 +18,10 @@ export interface CompanyPortabilityEnvInput {
requirement: "required" | "optional";
defaultValue: string | null;
portability: "portable" | "system_dependent";
secretName?: string | null;
secretProvider?: string | null;
/** Binding type — stored in extension.inputs.env but not in the manifest type itself */
type?: "secret_ref" | "plain";
}
export type CompanyPortabilityFileEntry =
@@ -166,6 +170,15 @@ export interface CompanyPortabilityManifest {
projects: CompanyPortabilityProjectManifestEntry[];
issues: CompanyPortabilityIssueManifestEntry[];
envInputs: CompanyPortabilityEnvInput[];
secrets?: CompanyPortabilitySecretEntry[];
}
export interface CompanyPortabilitySecretEntry {
name: string;
provider: SecretProvider;
description: string | null;
latestVersion: number;
currentValue: string;
}
export interface CompanyPortabilityExportResult {
@@ -317,4 +330,5 @@ export interface CompanyPortabilityExportRequest {
selectedFiles?: string[];
expandReferencedSkills?: boolean;
sidebarOrder?: Partial<CompanyPortabilitySidebarOrder>;
includeSecrets?: boolean;
}
+1
View File
@@ -306,6 +306,7 @@ export type {
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
CompanyPortabilitySecretEntry,
} from "./company-portability.js";
export type {
JsonSchema,
@@ -21,6 +21,9 @@ export const portabilityEnvInputSchema = z.object({
requirement: z.enum(["required", "optional"]),
defaultValue: z.string().nullable(),
portability: z.enum(["portable", "system_dependent"]),
secretName: z.string().min(1).nullable().optional(),
secretProvider: z.string().min(1).nullable().optional(),
type: z.enum(["secret_ref", "plain"]).optional(),
});
export const portabilityFileEntrySchema = z.union([
@@ -175,6 +178,13 @@ export const portabilityManifestSchema = z.object({
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
issues: z.array(portabilityIssueManifestEntrySchema).default([]),
envInputs: z.array(portabilityEnvInputSchema).default([]),
secrets: z.array(z.object({
name: z.string().min(1),
provider: z.string().min(1),
description: z.string().nullable(),
latestVersion: z.number().int().nonnegative(),
currentValue: z.string(),
})).optional(),
});
export const portabilitySourceSchema = z.discriminatedUnion("type", [
@@ -217,6 +227,7 @@ export const companyPortabilityExportSchema = z.object({
selectedFiles: z.array(z.string().min(1)).optional(),
expandReferencedSkills: z.boolean().optional(),
sidebarOrder: portabilitySidebarOrderSchema.partial().optional(),
includeSecrets: z.boolean().optional(),
});
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
@@ -68,6 +68,11 @@ export const companySkillUpdateStatusSchema = z.object({
export const companySkillImportSchema = z.object({
source: z.string().min(1),
authToken: z.string().min(1).optional(),
});
export const companySkillUpdateAuthSchema = z.object({
authToken: z.string().min(1).nullable(),
});
export const companySkillProjectScanRequestSchema = z.object({
@@ -135,3 +140,4 @@ export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
+2
View File
@@ -63,6 +63,7 @@ export {
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -74,6 +75,7 @@ export {
type CompanySkillProjectScan,
type CompanySkillCreate,
type CompanySkillFileUpdate,
type CompanySkillUpdateAuth,
} from "./company-skill.js";
export {
agentSkillStateSchema,
@@ -14,6 +14,7 @@ const companySvc = {
const agentSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
@@ -27,6 +28,7 @@ const accessSvc = {
const projectSvc = {
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
@@ -62,6 +64,26 @@ const assetSvc = {
const secretSvc = {
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env as Record<string, unknown>),
getById: vi.fn(async (id: string) => {
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
return null;
}),
resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: "latest") => {
if (secretId === "secret-1") return "sk-ant-secret-xxx";
if (secretId === "secret-2") return "ghp_secretxxx";
throw new Error("Secret not found");
}),
create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
id: `new-secret-${input.name}`,
companyId,
name: input.name,
provider: input.provider,
description: input.description ?? null,
latestVersion: 1,
})),
getByName: vi.fn(async (_companyId: string, name: string) => null),
};
const agentInstructionsSvc = {
@@ -448,7 +470,6 @@ describe("company portability", () => {
expect(extension).not.toContain("instructionsFilePath");
expect(extension).not.toContain("command:");
expect(extension).not.toContain("secretId");
expect(extension).not.toContain('type: "secret_ref"');
expect(extension).toContain("inputs:");
expect(extension).toContain("ANTHROPIC_API_KEY:");
expect(extension).toContain('requirement: "optional"');
@@ -1199,6 +1220,9 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: "anthropic-api-key",
secretProvider: "local_encrypted",
type: "secret_ref",
},
{
key: "GH_TOKEN",
@@ -1209,6 +1233,9 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: "gh-token",
secretProvider: "local_encrypted",
type: "secret_ref",
},
]);
});
@@ -1332,6 +1359,9 @@ describe("company portability", () => {
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: null,
secretProvider: null,
type: "plain",
});
});
@@ -2646,6 +2676,191 @@ describe("company portability", () => {
}));
});
describe("secret env vars", () => {
beforeEach(() => {
// Reset create/getByName to ensure clean state per test
secretSvc.create.mockReset();
secretSvc.getByName.mockReset();
secretSvc.getById.mockImplementation(async (id: string) => {
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
return null;
});
secretSvc.resolveSecretValue.mockImplementation(async (_companyId: string, secretId: string) => {
if (secretId === "secret-1") return "sk-ant-secret-xxx";
if (secretId === "secret-2") return "ghp_secretxxx";
throw new Error("Secret not found");
});
secretSvc.create.mockImplementation(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
id: `new-secret-${input.name}`,
companyId,
name: input.name,
provider: input.provider,
description: input.description ?? null,
latestVersion: 1,
}));
secretSvc.getByName.mockResolvedValue(null);
});
it("exports secret env var metadata with secretName and secretProvider", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
});
const secretInput = exported.manifest.envInputs.find(
(e: any) => e.key === "ANTHROPIC_API_KEY" && e.kind === "secret",
);
expect(secretInput).toBeDefined();
expect(secretInput.secretName).toBe("anthropic-api-key");
expect(secretInput.secretProvider).toBe("local_encrypted");
});
it("exports secret values to manifest when includeSecrets is true", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
expect(exported.manifest.secrets).toBeDefined();
expect(exported.manifest.secrets).toContainEqual(expect.objectContaining({
name: "anthropic-api-key",
provider: "local_encrypted",
currentValue: "sk-ant-secret-xxx",
}));
});
it("omits secrets section when includeSecrets is false", async () => {
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: false,
});
expect(exported.manifest.secrets).toBeUndefined();
});
it("writes placeholder when resolveSecretValue throws (cross-instance decryption failure)", async () => {
secretSvc.resolveSecretValue.mockImplementation(async () => {
throw new Error("Decryption failed: missing master key");
});
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
const secretEntry = exported.manifest.secrets?.find((s: any) => s.name === "anthropic-api-key");
expect(secretEntry?.currentValue).toBe("<decryption-key-missing:anthropic-api-key>");
expect(exported.warnings).toContainEqual(expect.stringContaining("could not be decrypted during export"));
});
it("imports secrets and remaps secret_ref bindings to new secret IDs", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
id: "new-agent-1",
companyId,
...patch,
}));
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
agentSvc.getById.mockImplementation(async (id: string) => {
if (id === "new-agent-1") {
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
}
return null;
});
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
const imported = await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { agents: true, company: false, projects: false, issues: false, skills: false },
target: { mode: "existing_company", companyId: "company-imported" },
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(secretSvc.create).toHaveBeenCalled();
expect(agentSvc.update).toHaveBeenCalledWith(
"new-agent-1",
expect.any(Object),
);
});
it("reuses existing secret on conflict during import", async () => {
secretSvc.getByName.mockImplementation(async (_companyId: string, name: string) => {
if (name === "anthropic-api-key") return { id: "existing-secret-1", name, provider: "local_encrypted" };
return null;
});
const portability = companyPortabilityService({} as any);
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
id: "new-agent-1",
companyId,
...patch,
}));
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
agentSvc.getById.mockImplementation(async (id: string) => {
if (id === "new-agent-1") {
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
}
return null;
});
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["claudecoder"],
includeSecrets: true,
});
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { agents: true, company: false, projects: false, issues: false, skills: false },
target: { mode: "existing_company", companyId: "company-imported" },
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(agentSvc.update).toHaveBeenCalled();
});
it("exports plain env vars faithfully", async () => {
agentSvc.list.mockResolvedValue([{
id: "agent-1",
name: "TestAgent",
status: "idle",
role: "agent",
title: null,
icon: null,
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {
env: {
PLAIN_VAR: { type: "plain", value: "plain-value" },
ANOTHER_VAR: { type: "plain", value: "another-value" },
},
},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
}]);
const portability = companyPortabilityService({} as any);
const exported = await portability.exportBundle("company-1", {
include: { agents: true, company: false, projects: false, issues: false, skills: false },
agents: ["testagent"],
});
const plainInputs = exported.manifest.envInputs.filter((e: any) => e.kind === "plain");
expect(plainInputs).toContainEqual(expect.objectContaining({
key: "PLAIN_VAR",
defaultValue: "plain-value",
}));
expect(plainInputs).toContainEqual(expect.objectContaining({
key: "ANOTHER_VAR",
defaultValue: "another-value",
}));
});
});
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
const portability = companyPortabilityService({} as any);
@@ -14,6 +14,8 @@ const mockAccessService = vi.hoisted(() => ({
const mockCompanySkillService = vi.hoisted(() => ({
importFromSource: vi.fn(),
deleteSkill: vi.fn(),
updateSkillAuth: vi.fn(),
scanProjectWorkspaces: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -97,6 +99,15 @@ describe("company skill mutation permissions", () => {
slug: "find-skills",
name: "Find Skills",
});
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
scannedProjects: 1,
scannedWorkspaces: 2,
discovered: [],
imported: [],
updated: [],
conflicts: [],
warnings: [],
});
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
@@ -294,9 +305,120 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
undefined,
);
});
it("passes a PAT through skill import requests", async () => {
const res = await request(await createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}))
.post("/api/companies/company-1/skills/import")
.send({
source: "https://github.com/vercel-labs/agent-browser",
authToken: "ghp_private_token",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
"ghp_private_token",
);
});
it("updates a skill auth token", async () => {
mockCompanySkillService.updateSkillAuth.mockResolvedValue({
id: "skill-1",
slug: "find-skills",
});
const res = await request(await createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}))
.patch("/api/companies/company-1/skills/skill-1/auth")
.send({ authToken: "ghp_private_token" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
"company-1",
"skill-1",
"ghp_private_token",
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "company.skill_auth_updated",
entityType: "company_skill",
entityId: "skill-1",
details: { slug: "find-skills" },
}),
);
});
it("clears a skill auth token", async () => {
mockCompanySkillService.updateSkillAuth.mockResolvedValue({
id: "skill-1",
slug: "find-skills",
});
const res = await request(await createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}))
.patch("/api/companies/company-1/skills/skill-1/auth")
.send({ authToken: null });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
"company-1",
"skill-1",
null,
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "company.skill_auth_removed",
entityType: "company_skill",
entityId: "skill-1",
details: { slug: "find-skills" },
}),
);
});
it("allows agents with canCreateAgents to scan project workspaces", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
permissions: { canCreateAgents: true },
});
const res = await request(await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/companies/company-1/skills/scan-projects")
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {});
});
it("returns a blocking error when attempting to delete a skill still used by agents", async () => {
const { unprocessable } = await import("../errors.js");
mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => {
+36 -1
View File
@@ -4,6 +4,7 @@ import {
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
@@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
const result = await svc.importFromSource(companyId, source);
const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
const result = await svc.importFromSource(companyId, source, authToken || undefined);
const actor = getActorInfo(req);
await logActivity(db, {
@@ -318,5 +320,38 @@ export function companySkillRoutes(db: Db) {
res.json(result);
});
router.patch(
"/companies/:companyId/skills/:skillId/auth",
validate(companySkillUpdateAuthSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const authToken = req.body.authToken as string | null;
const result = await svc.updateSkillAuth(companyId, skillId, authToken);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
},
});
res.json(result);
},
);
return router;
}
+256 -19
View File
@@ -26,9 +26,11 @@ import type {
CompanyPortabilityIssueManifestEntry,
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
CompanyPortabilitySecretEntry,
CompanySkill,
AgentEnvConfig,
RoutineVariable,
SecretProvider,
} from "@paperclipai/shared";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
@@ -50,7 +52,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js";
@@ -399,7 +401,7 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
return parsed.success ? parsed.data : null;
}
function extractPortableScopedEnvInputs(
async function extractPortableScopedEnvInputs(
scope: {
label: string;
warningPrefix: string;
@@ -408,7 +410,11 @@ function extractPortableScopedEnvInputs(
},
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record<string, unknown>;
const inputs: CompanyPortabilityEnvInput[] = [];
@@ -420,6 +426,7 @@ function extractPortableScopedEnvInputs(
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
const secret = await secrets.getById(String(binding.secretId));
inputs.push({
key,
description: `Provide ${key} for ${scope.label}`,
@@ -429,7 +436,33 @@ function extractPortableScopedEnvInputs(
requirement: "optional",
defaultValue: "",
portability: "portable",
secretName: secret?.name ?? null,
secretProvider: secret?.provider ?? null,
});
if (includeSecrets && secret && binding.secretId) {
const alreadyExported = secretEntries.some((e) => e.name === secret.name);
if (!alreadyExported) {
try {
const resolvedValue = await secrets.resolveSecretValue(companyId, String(binding.secretId), "latest");
secretEntries.push({
name: secret.name,
provider: secret.provider as SecretProvider,
description: secret.description,
latestVersion: secret.latestVersion,
currentValue: resolvedValue,
});
} catch {
secretEntries.push({
name: secret.name,
provider: secret.provider as SecretProvider,
description: secret.description,
latestVersion: secret.latestVersion,
currentValue: `<decryption-key-missing:${secret.name}>`,
});
warnings.push(`Secret "${secret.name}" could not be decrypted during export. Placeholder written.`);
}
}
}
continue;
}
@@ -439,9 +472,6 @@ function extractPortableScopedEnvInputs(
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -457,9 +487,6 @@ function extractPortableScopedEnvInputs(
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
@@ -567,11 +594,14 @@ type AgentLike = {
};
type EnvInputRecord = {
type?: "secret_ref" | "plain";
kind: "secret" | "plain";
requirement: "required" | "optional";
default?: string | null;
description?: string | null;
portability?: "portable" | "system_dependent";
secretName?: string | null;
secretProvider?: string | null;
};
const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
@@ -1623,11 +1653,15 @@ function isAbsoluteCommand(value: string) {
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
}
function extractPortableEnvInputs(
async function extractPortableEnvInputs(
agentSlug: string,
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
return extractPortableScopedEnvInputs(
{
label: `agent ${agentSlug}`,
@@ -1637,14 +1671,22 @@ function extractPortableEnvInputs(
},
envValue,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
}
function extractPortableProjectEnvInputs(
async function extractPortableProjectEnvInputs(
projectSlug: string,
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
secretEntries: CompanyPortabilitySecretEntry[],
includeSecrets: boolean,
companyId: string,
): Promise<CompanyPortabilityEnvInput[]> {
return extractPortableScopedEnvInputs(
{
label: `project ${projectSlug}`,
@@ -1654,6 +1696,10 @@ function extractPortableProjectEnvInputs(
},
envValue,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
}
@@ -2258,6 +2304,13 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
if (input.defaultValue !== null) entry.default = input.defaultValue;
if (input.description) entry.description = input.description;
if (input.portability === "system_dependent") entry.portability = "system_dependent";
if (input.secretName) {
entry.secretName = input.secretName;
entry.type = "secret_ref";
} else {
entry.type = "plain";
}
if (input.secretProvider) entry.secretProvider = input.secretProvider;
env[input.key] = entry;
}
return env;
@@ -2302,6 +2355,9 @@ function readAgentEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
secretName: record.secretName ?? null,
secretProvider: record.secretProvider ?? null,
type: record.type,
}];
});
}
@@ -2326,6 +2382,9 @@ function readProjectEnvInputs(
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
secretName: record.secretName ?? null,
secretProvider: record.secretProvider ?? null,
type: record.type,
}];
});
}
@@ -2372,6 +2431,7 @@ function buildManifestFromPackageFiles(
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {};
const paperclipSecrets = Array.isArray(paperclipExtension.secrets) ? paperclipExtension.secrets : [];
const companyName =
asString(companyFrontmatter.name)
?? opts?.sourceLabel?.companyName
@@ -2455,6 +2515,7 @@ function buildManifestFromPackageFiles(
projects: [],
issues: [],
envInputs: [],
secrets: paperclipSecrets.length > 0 ? paperclipSecrets : undefined,
};
const warnings: string[] = [];
@@ -2969,7 +3030,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const files: Record<string, CompanyPortabilityFileEntry> = {};
const warnings: string[] = [];
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
const secretEntries: CompanyPortabilitySecretEntry[] = [];
const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder);
const includeSecrets = input.includeSecrets === true;
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
let companyLogoPath: string | null = null;
@@ -3249,10 +3312,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(...exportedInstructions.warnings);
const envInputsStart = envInputs.length;
const exportedEnvInputs = extractPortableEnvInputs(
const exportedEnvInputs = await extractPortableEnvInputs(
slug,
(agent.adapterConfig as Record<string, unknown>).env,
warnings,
secrets,
secretEntries,
includeSecrets,
companyId,
);
envInputs.push(...exportedEnvInputs);
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
@@ -3329,7 +3396,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const slug = projectSlugById.get(project.id)!;
const projectPath = `projects/${slug}/PROJECT.md`;
const envInputsStart = envInputs.length;
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
const exportedEnvInputs = await extractPortableProjectEnvInputs(slug, project.env, warnings, secrets, secretEntries, includeSecrets, companyId);
envInputs.push(...exportedEnvInputs);
const projectEnvInputs = dedupeEnvInputs(
envInputs
@@ -3534,8 +3601,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
skills: resolved.manifest.skills.length > 0,
};
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
if (includeSecrets) {
resolved.manifest.secrets = secretEntries.length > 0 ? secretEntries : undefined;
}
resolved.warnings.unshift(...warnings);
// Rebuild the YAML file to include secrets so files stay in sync with manifest
// Only include secrets - other fields should come from the original YAML structure
if (includeSecrets && resolved.manifest.secrets) {
// Parse existing YAML and add secrets to it
const existingYaml = parseYamlFile(readPortableTextFile(finalFiles, paperclipExtensionPath) ?? "") ?? {};
existingYaml.secrets = resolved.manifest.secrets;
finalFiles[paperclipExtensionPath] = buildYamlFile(existingYaml, { preserveEmptyStrings: true });
}
return {
rootPath,
manifest: resolved.manifest,
@@ -4093,6 +4172,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map<string, string>();
const secretNameToId = new Map<string, string>();
const existingSlugToAgentId = new Map<string, string>();
const agentStatusById = new Map<string, string | null | undefined>();
const existingAgents = await agents.list(targetCompany.id);
@@ -4124,6 +4204,35 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
// Create secrets in target company and build name->id map
for (const secretEntry of sourceManifest.secrets ?? []) {
if (secretEntry.currentValue.startsWith("<decryption-key-missing:")) {
warnings.push(`Secret "${secretEntry.name}" could not be decrypted in source instance. ` +
`Placeholder written for key. Create a secret with this name and update manually.`);
continue;
}
try {
const created = await secrets.create(targetCompany.id, {
name: secretEntry.name,
provider: secretEntry.provider,
value: secretEntry.currentValue,
description: secretEntry.description,
});
secretNameToId.set(secretEntry.name, created.id);
} catch (err) {
if (err instanceof HttpError && err.status === 409) {
const existing = await secrets.getByName(targetCompany.id, secretEntry.name);
if (existing) {
secretNameToId.set(secretEntry.name, existing.id);
} else {
warnings.push(`Secret "${secretEntry.name}" already exists but could not be resolved by name. Re-add env bindings for this secret manually.`);
}
} else {
warnings.push(`Failed to create secret "${secretEntry.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
if (include.agents) {
for (const planAgent of plan.preview.plan.agentPlans) {
const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug);
@@ -4180,6 +4289,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
desiredSkills,
mode,
);
// Reconstruct adapterConfig.env from manifest.envInputs for this agent
const agentEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.agentSlug === manifestAgent.slug);
if (agentEnvInputs.length > 0) {
const env: Record<string, unknown> = {};
for (const ei of agentEnvInputs) {
if (ei.kind === "secret" && ei.secretName) {
const newSecretId = secretNameToId.get(ei.secretName);
if (newSecretId) {
env[ei.key] = { type: "secret_ref", secretId: newSecretId };
} else {
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
}
} else if (ei.kind === "secret" && !ei.secretName) {
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
env[ei.key] = { type: "plain", value: ei.defaultValue };
}
}
if (Object.keys(env).length > 0) {
normalizedAdapter.adapterConfig.env = await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, env as any, { strictMode: strictSecretsMode });
}
}
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@@ -4230,10 +4363,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
const createdStatus = "idle";
let created = await agents.create(targetCompany.id, {
...patch,
status: createdStatus,
status: "idle",
});
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
@@ -4253,7 +4385,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? createdStatus);
agentStatusById.set(created.id, created.status ?? "idle");
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
@@ -4302,6 +4434,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
?? null
: null;
const projectWorkspaceIdByKey = new Map<string, string>();
// Build project env from manifest.envInputs filtered by this project
const projectEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.projectSlug === planProject.slug);
const reconstructedProjectEnv: Record<string, unknown> = {};
for (const ei of projectEnvInputs) {
if (ei.kind === "secret" && ei.secretName) {
const newSecretId = secretNameToId.get(ei.secretName);
if (newSecretId) {
reconstructedProjectEnv[ei.key] = { type: "secret_ref", secretId: newSecretId };
} else {
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
}
} else if (ei.kind === "secret" && !ei.secretName) {
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
reconstructedProjectEnv[ei.key] = { type: "plain", value: ei.defaultValue };
}
}
const projectEnvConfig = Object.keys(reconstructedProjectEnv).length > 0
? await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, reconstructedProjectEnv as any, { strictMode: strictSecretsMode })
: null;
const projectPatch = {
name: planProject.plannedName,
description: manifestProject.description,
@@ -4311,7 +4463,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog",
env: manifestProject.env,
env: projectEnvConfig ?? undefined,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
};
@@ -4390,6 +4542,91 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
}
// Remap secret_ref bindings in imported agent/project records to target company secret IDs
for (const envInput of sourceManifest.envInputs ?? []) {
if (envInput.kind !== "secret" || !envInput.secretName) continue;
const newSecretId = secretNameToId.get(envInput.secretName);
if (!newSecretId) {
// secret wasn't created (decryption failure or error) — it's already a placeholder in the env
continue;
}
if (envInput.agentSlug) {
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
if (agentId) {
const agent = await agents.getById(agentId);
if (agent) {
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
const env = adapterConfig.env as Record<string, unknown> | undefined;
let mutated = false;
if (env && typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
const binding = env[envInput.key] as Record<string, unknown>;
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
binding.secretId = newSecretId;
mutated = true;
}
}
if (mutated) await agents.update(agentId, { adapterConfig });
}
}
} else if (envInput.projectSlug) {
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
if (projectId) {
const project = await projects.getById(projectId);
if (project && project.env && typeof project.env === "object") {
const env = project.env as Record<string, unknown>;
let mutated = false;
if (typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
const binding = env[envInput.key] as Record<string, unknown>;
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
binding.secretId = newSecretId;
mutated = true;
}
}
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
}
}
}
}
// Note: the legacy secret remapping below is kept as a safety net for
// agents/projects that were created/updated before this code existed.
// It can be removed once the inline reconstruction above is stable.
// Reconstruct plain env bindings and fill in missing env keys on imported agents/projects
for (const envInput of sourceManifest.envInputs ?? []) {
if (envInput.kind !== "plain" && !(envInput.kind === "secret" && !envInput.secretName)) continue;
if (!envInput.defaultValue && envInput.kind === "plain") continue;
if (envInput.agentSlug) {
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
if (!agentId) continue;
const agent = await agents.getById(agentId);
if (!agent) continue;
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
const env = (adapterConfig.env as Record<string, unknown>) ?? {};
let mutated = false;
if (!env[envInput.key] && envInput.kind === "plain") {
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
mutated = true;
}
if (mutated) {
adapterConfig.env = env;
await agents.update(agentId, { adapterConfig });
}
} else if (envInput.projectSlug) {
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
if (!projectId) continue;
const project = await projects.getById(projectId);
if (!project) continue;
const env = (project.env as Record<string, unknown>) ?? {};
let mutated = false;
if (!env[envInput.key] && envInput.kind === "plain") {
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
mutated = true;
}
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
}
}
if (include.issues) {
const routines = routineService(db);
for (const manifestIssue of sourceManifest.issues) {
+153 -17
View File
@@ -32,6 +32,7 @@ import { notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
import { secretService } from "./secrets.js";
type CompanySkillRow = typeof companySkills.$inferSelect;
type CompanySkillListDbRow = Pick<
@@ -540,20 +541,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
};
}
async function fetchText(url: string) {
const response = await ghFetch(url);
async function fetchText(url: string, authToken?: string) {
const response = await ghFetch(url, undefined, authToken);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
async function fetchJson<T>(url: string): Promise<T> {
async function fetchJson<T>(url: string, authToken?: string): Promise<T> {
const response = await ghFetch(url, {
headers: {
accept: "application/vnd.github+json",
},
});
}, authToken);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
@@ -561,16 +562,18 @@ async function fetchJson<T>(url: string): Promise<T> {
}
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) {
const response = await fetchJson<{ default_branch?: string }>(
`${apiBase}/repos/${owner}/${repo}`,
authToken,
);
return asString(response.default_branch) ?? "main";
}
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) {
const response = await fetchJson<{ sha?: string }>(
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
authToken,
);
const sha = asString(response.sha);
if (!sha) {
@@ -607,7 +610,7 @@ function parseGitHubSourceUrl(rawUrl: string) {
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
}
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>, authToken?: string) {
const apiBase = gitHubApiBase(parsed.hostname);
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
return {
@@ -618,8 +621,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourc
const trackingRef = parsed.explicitRef
? parsed.ref
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase, authToken);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase, authToken);
return { pinnedRef, trackingRef };
}
@@ -1050,6 +1053,7 @@ async function readUrlSkillImports(
companyId: string,
sourceUrl: string,
requestedSkillSlug: string | null = null,
authToken?: string,
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
const url = sourceUrl.trim();
const warnings: string[] = [];
@@ -1064,10 +1068,11 @@ async function readUrlSkillImports(
if (looksLikeRepoUrl) {
const parsed = parseGitHubSourceUrl(url);
const apiBase = gitHubApiBase(parsed.hostname);
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken);
let ref = pinnedRef;
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
authToken,
).catch(() => {
throw unprocessable(`Failed to read GitHub tree for ${url}`);
});
@@ -1094,7 +1099,7 @@ async function readUrlSkillImports(
const skills: ImportedSkill[] = [];
for (const relativeSkillPath of skillPaths) {
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken);
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const skillDir = path.posix.dirname(relativeSkillPath);
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
@@ -1156,7 +1161,7 @@ async function readUrlSkillImports(
}
if (url.startsWith("http://") || url.startsWith("https://")) {
const markdown = await fetchText(url);
const markdown = await fetchText(url, authToken);
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const urlObj = new URL(url);
const fileName = path.posix.basename(urlObj.pathname);
@@ -1548,6 +1553,22 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
export function companySkillService(db: Db) {
const agents = agentService(db);
const projects = projectService(db);
const secretsSvc = secretService(db);
async function resolveSkillAuthToken(
companyId: string,
skill: { metadata: Record<string, unknown> | null },
): Promise<string | undefined> {
const meta = skill.metadata;
if (!meta) return undefined;
const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : "";
if (!secretId) return undefined;
try {
return await secretsSvc.resolveSecretValue(companyId, secretId, "latest");
} catch {
return undefined;
}
}
async function ensureBundledSkills(companyId: string) {
for (const skillsRoot of resolveBundledSkillsRoot()) {
@@ -1766,7 +1787,8 @@ export function companySkillService(db: Db) {
const hostname = asString(metadata.hostname) || "github.com";
const apiBase = gitHubApiBase(hostname);
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
const authToken = await resolveSkillAuthToken(companyId, skill);
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken);
return {
supported: true,
reason: null,
@@ -1810,8 +1832,9 @@ export function companySkillService(db: Db) {
if (!owner || !repo) {
throw unprocessable("Skill source metadata is incomplete.");
}
const authToken = await resolveSkillAuthToken(companyId, skill);
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken);
} else if (skill.sourceType === "url") {
if (normalizedPath !== "SKILL.md") {
throw notFound("This skill source only exposes SKILL.md");
@@ -1928,7 +1951,8 @@ export function companySkillService(db: Db) {
throw unprocessable("Skill source locator is missing.");
}
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
const authToken = await resolveSkillAuthToken(companyId, skill);
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken);
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
if (!matching) {
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
@@ -2103,6 +2127,28 @@ export function companySkillService(db: Db) {
}
}
const sourceLocators = new Set<string>();
for (const skill of acceptedSkills) {
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") continue;
const locator = skill.sourceLocator ?? "";
if (locator) sourceLocators.add(locator);
}
for (const sourceLocator of sourceLocators) {
try {
const result = await readUrlSkillImports(companyId, sourceLocator, null);
for (const nextSkill of result.skills) {
if (acceptedSkills.some((s) => s.slug === nextSkill.slug)) continue;
const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
if (persisted) {
imported.push(persisted);
upsertAcceptedSkill(persisted);
}
}
} catch {
warnings.push(`Could not re-scan source ${sourceLocator} — skipping.`);
}
}
return {
scannedProjects: scannedProjectIds.size,
scannedWorkspaces: scanTargets.length,
@@ -2340,6 +2386,9 @@ export function companySkillService(db: Db) {
const metadata = {
...(skill.metadata ?? {}),
skillKey: skill.key,
...(existing?.metadata && typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
: {}),
};
const values = {
companyId,
@@ -2375,7 +2424,7 @@ export function companySkillService(db: Db) {
return out;
}
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
await ensureSkillInventoryCurrent(companyId);
const parsed = parseSkillImportSourceInput(source);
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
@@ -2385,7 +2434,7 @@ export function companySkillService(db: Db) {
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
warnings: parsed.warnings,
}
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
.then((result) => ({
skills: result.skills,
warnings: [...parsed.warnings, ...result.warnings],
@@ -2412,6 +2461,33 @@ export function companySkillService(db: Db) {
}
}
const imported = await upsertImportedSkills(companyId, filteredSkills);
if (authToken && imported.length > 0) {
for (const skill of imported) {
const secretName = `skill-pat:${skill.id}`;
let secretId: string;
const existing = await secretsSvc.getByName(companyId, secretName);
if (existing) {
await secretsSvc.rotate(existing.id, { value: authToken });
secretId = existing.id;
} else {
const created = await secretsSvc.create(companyId, {
name: secretName,
provider: "local_encrypted",
value: authToken,
description: `GitHub PAT for skill ${skill.slug}`,
});
secretId = created.id;
}
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
meta.sourceAuthSecretId = secretId;
await db
.update(companySkills)
.set({ metadata: meta, updatedAt: new Date() })
.where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId)));
}
}
return { imported, warnings };
}
@@ -2451,9 +2527,68 @@ export function companySkillService(db: Db) {
// Clean up materialized runtime files
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
const meta = skill.metadata as Record<string, unknown> | null;
const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
if (secretId) {
try {
await secretsSvc.remove(secretId);
} catch {
// Best-effort: don't fail the skill deletion if secret cleanup fails
}
}
return skill;
}
async function updateSkillAuth(
companyId: string,
skillId: string,
authToken: string | null,
): Promise<CompanySkill | null> {
const skill = await getById(companyId, skillId);
if (!skill) return null;
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
if (authToken) {
const secretName = `skill-pat:${skill.id}`;
let secretId: string;
const existingSecret = existingSecretId
? await secretsSvc.getById(existingSecretId)
: await secretsSvc.getByName(companyId, secretName);
if (existingSecret) {
await secretsSvc.rotate(existingSecret.id, { value: authToken });
secretId = existingSecret.id;
} else {
const created = await secretsSvc.create(companyId, {
name: secretName,
provider: "local_encrypted",
value: authToken,
description: `GitHub PAT for skill ${skill.slug}`,
});
secretId = created.id;
}
meta.sourceAuthSecretId = secretId;
} else {
if (existingSecretId) {
try {
await secretsSvc.remove(existingSecretId);
} catch {
// Best-effort: don't fail the metadata update if secret deletion fails
}
}
delete meta.sourceAuthSecretId;
}
const [updated] = await db
.update(companySkills)
.set({ metadata: meta, updatedAt: new Date() })
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
.returning();
return updated ? toCompanySkill(updated) : null;
}
return {
list,
listFull,
@@ -2470,6 +2605,7 @@ export function companySkillService(db: Db) {
createLocalSkill,
deleteSkill,
importFromSource,
updateSkillAuth,
scanProjectWorkspaces,
importPackageFiles,
installUpdate,
+6 -2
View File
@@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
}
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise<Response> {
const headers = new Headers(init?.headers);
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
try {
return await fetch(url, init);
return await fetch(url, { ...init, headers, redirect: authToken ? "manual" : "follow" });
} catch {
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
}
+2 -2
View File
@@ -57,8 +57,8 @@ import { logger } from "../middleware/logger.js";
/** Default timeout for RPC calls in milliseconds. */
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
/** Hard upper bound for any RPC timeout (5 minutes). Prevents unbounded waits. */
const MAX_RPC_TIMEOUT_MS = 5 * 60 * 1_000;
/** Hard upper bound for any RPC timeout (60 minutes). Prevents unbounded waits. */
const MAX_RPC_TIMEOUT_MS = 60 * 60 * 1_000;
/** Timeout for the initialize RPC call. */
const INITIALIZE_TIMEOUT_MS = 15_000;
-267
View File
@@ -1,267 +0,0 @@
---
name: paperclip-dev
required: false
description: >
Develop and operate a local Paperclip instance — start and stop servers,
pull updates from master, run builds and tests, manage worktrees, back up
databases, and diagnose problems. Use whenever you need to work on the
Paperclip codebase itself or keep a running instance healthy.
---
# Paperclip Dev
This skill covers the day-to-day workflows for developing and operating a local Paperclip instance. It assumes you are working inside the Paperclip repo checkout with `origin` pointing to `git@github.com:paperclipai/paperclip.git`.
> **OPEN SOURCE HYGIENE:** This repository is public-facing. Treat anything you push to `origin` as publishable. Never commit or push secrets, API keys, tokens, private logs, PII, customer data, or machine-local configuration that should stay private. Keep git history tidy as well: avoid pushing throwaway branches, noisy checkpoint commits, or speculative work that does not need to be shared upstream.
> **MANDATORY:** Before running any CLI command, building, testing, or managing worktrees, you MUST read `doc/DEVELOPING.md` in the Paperclip repo. It is the canonical reference for all `paperclipai` CLI commands, their options, build/test workflows, database operations, worktree management, and diagnostics. Do NOT guess at flags or options — read the doc first.
## Quick Command Reference
These are the most common commands. For full option tables and details, see `doc/DEVELOPING.md`.
| Task | Command |
|------|---------|
| Start server (first time or normal) | `npx paperclipai run` |
| Dev mode with hot reload | `pnpm dev` |
| Stop dev server | `pnpm dev:stop` |
| Build | `pnpm build` |
| Type-check | `pnpm typecheck` |
| Run tests | `pnpm test` |
| Run migrations | `pnpm db:migrate` |
| Regenerate Drizzle client | `pnpm db:generate` |
| Back up database | `npx paperclipai db:backup` |
| Health check | `npx paperclipai doctor --repair` |
| Print env vars | `npx paperclipai env` |
| Trigger agent heartbeat | `npx paperclipai heartbeat run --agent-id <id>` |
| Install agent skills locally | `npx paperclipai agent local-cli <agent> --company-id <id>` |
## Pulling from Master
```bash
git fetch origin && git pull origin master
pnpm install && pnpm build
```
If schema changes landed, also run `pnpm db:generate && pnpm db:migrate`.
## Worktrees
Paperclip worktrees combine git worktrees with isolated Paperclip instances — each gets its own database, server port, and environment seeded from the primary instance.
> **MANDATORY:** Before creating or managing worktrees, you MUST read the "Worktree-local Instances" and "Worktree CLI Reference" sections in `doc/DEVELOPING.md`. That is the canonical reference for all worktree commands, their options, seed modes, and environment variables.
### When to Use Worktrees
- Starting a feature branch that needs its own Paperclip environment
- Running parallel agent work without cross-contaminating the primary instance
- Testing Paperclip changes in isolation before merging
### Command Overview
The CLI has two tiers (see `doc/DEVELOPING.md` for full option tables):
| Command | Purpose |
|---------|---------|
| `worktree:make <name>` | Create worktree + isolated instance in one step |
| `worktree:list` | List worktrees and their Paperclip status |
| `worktree:merge-history` | Preview/import issue history between worktrees |
| `worktree:cleanup <name>` | Remove worktree, branch, and instance data |
| `worktree init` | Bootstrap instance inside existing worktree |
| `worktree env` | Print shell exports for worktree instance |
| `worktree reseed` | Refresh worktree DB from another instance |
| `worktree repair` | Fix broken/missing worktree instance metadata |
### Typical Workflow
```bash
# 1. Create a worktree for a feature
npx paperclipai worktree:make my-feature --start-point origin/main
# 2. Move into the worktree (path printed by worktree:make) and source the environment
cd <worktree-path>
eval "$(npx paperclipai worktree env)"
# 3. Start the isolated Paperclip server
npx paperclipai run
# 4. Do your work
# 5. When done, merge history back if needed
npx paperclipai worktree:merge-history --from paperclip-my-feature --to current --apply
# 6. Clean up
npx paperclipai worktree:cleanup my-feature
```
## Forks — Prefer Pushing to a User Fork
If the user has a personal fork of `paperclipai/paperclip` configured as a git remote, push your feature branches to **that fork** instead of creating branches on the main repo. This keeps the upstream branch list clean and matches the standard open-source contribution flow.
### Detect a fork remote
Before pushing or creating a PR, list remotes and check for one that points at a non-`paperclipai` GitHub fork:
```bash
git remote -v
```
Treat any remote whose URL points to `github.com:<user>/paperclip` (or `github.com/<user>/paperclip.git`) as the user's fork. Common names are `fork`, `<username>`, or `myfork`. The remote named `origin` or `upstream` that points at `paperclipai/paperclip` is the canonical upstream — do not push feature branches there if a fork exists.
### Pushing to the fork
```bash
# Push the current branch to the user's fork and set upstream
git push -u <fork-remote> HEAD
```
Then create the PR from the fork branch:
```bash
gh pr create --repo paperclipai/paperclip --head <fork-owner>:<branch-name> ...
```
`gh pr create` usually figures out the head ref automatically when run from a branch tracking the fork; the explicit `--head <owner>:<branch>` form is the reliable fallback when it does not.
### When no fork exists
If `git remote -v` shows only `paperclipai/paperclip` remotes (no user fork), fall back to pushing branches to `origin` as before. Do NOT create a fork on the user's behalf — ask first.
### Keeping the fork up to date
The canonical remote that points at `paperclipai/paperclip` may be named `origin` **or** `upstream` depending on how the user set up the repo. Detect it the same way as in the "Detect a fork remote" step, then fetch and push from/with that remote so the sync works under either convention:
```bash
UPSTREAM_REMOTE=$(git remote -v | awk '/paperclipai\/paperclip.*\(fetch\)/{print $1; exit}')
git fetch "$UPSTREAM_REMOTE"
git push <fork-remote> "${UPSTREAM_REMOTE}/master:master"
```
## Pull Requests
> **MANDATORY PRE-FLIGHT:** Before creating ANY pull request, you MUST read the canonical source files listed below. Do NOT run `gh pr create` until you have read these files and verified your PR body matches every required section.
### Step 1 — Read the canonical files
You MUST read all three of these files before creating a PR:
1. **`.github/PULL_REQUEST_TEMPLATE.md`** — the required PR body structure
2. **`CONTRIBUTING.md`** — contribution conventions, PR requirements, and thinking-path examples
3. **`.github/workflows/pr.yml`** — CI checks that gate merge
### Step 2 — Validate your PR body against this checklist
After reading the template, verify your `--body` includes every one of these sections (names must match exactly):
- [ ] `## Thinking Path` — blockquote style, 5-8 reasoning steps
- [ ] `## What Changed` — bullet list of concrete changes
- [ ] `## Verification` — how a reviewer confirms this works
- [ ] `## Risks` — what could go wrong
- [ ] `## Model Used` — provider, model ID, version, capabilities
- [ ] `## Checklist` — copied from the template, items checked off
If any section is missing or empty, do NOT submit the PR. Go back and fill it in.
### Step 3 — Create the PR
Only after completing Steps 1 and 2, run `gh pr create`. Use the template contents as the structure for `--body` — do not write a freeform summary.
## Hard Rules — Do NOT Bypass
These rules exist because agents have caused real damage by improvising around CLI failures. Follow them exactly.
1. **CLI is the only interface to worktrees and databases.** All worktree and database operations MUST go through `npx paperclipai` / `pnpm paperclipai` commands. You MUST NOT:
- Run `pg_dump`, `pg_restore`, `psql`, `createdb`, `dropdb`, or any raw postgres commands
- Manually set `DATABASE_URL` to point a worktree server at another instance's database
- Run `rm -rf` on any `.paperclip/`, `.paperclip-worktrees/`, or `db/` directory
- Directly manipulate embedded postgres data directories
- Kill postgres processes by PID
2. **If a CLI command fails, stop and report.** Do NOT attempt workarounds. If `worktree:make`, `worktree reseed`, `worktree init`, `worktree:cleanup`, or any other `paperclipai` command fails:
- Report the exact error message in your task comment
- Set the task to `blocked`
- Suggest running `npx paperclipai doctor --repair` or recreating the worktree from scratch
- Do NOT try to manually replicate what the CLI does
3. **Never share databases between instances.** Each worktree instance gets its own isolated database. Never override `DATABASE_URL` to point one instance at another's database. This destroys isolation and can corrupt production data.
4. **Starting a dev server in a worktree requires setup first.** The correct sequence is:
```bash
# If the worktree already exists but has no running instance:
cd <worktree-path>
eval "$(npx paperclipai worktree env)"
pnpm install && pnpm build
npx paperclipai run # or pnpm dev
# If the worktree needs a fresh database:
npx paperclipai worktree reseed --seed-mode full
# If the worktree is broken beyond repair:
npx paperclipai worktree:cleanup <name>
npx paperclipai worktree:make <name> --seed-mode full
```
If any step fails, follow rule 2 — stop and report.
5. **Seeding is a CLI operation.** When asked to seed a worktree database from the main instance, use `worktree reseed` or recreate with `worktree:make --seed-mode full`. Read `doc/DEVELOPING.md` for the full option tables. Never attempt manual database copying.
## Persistent Dev Servers (for Manual Testing)
When an agent needs to start a dev server that outlives the current heartbeat — for example, so a human or QA agent can manually test against it — the server process **must** be launched in a detached session. A process started directly from a heartbeat shell is killed when the heartbeat exits.
### Use `tmux` for persistent servers
```bash
# 1. cd into the worktree (or main repo) and source the environment
cd <worktree-path>
eval "$(npx paperclipai worktree env)" # skip if using the primary instance
# 2. Start the dev server in a named, detached tmux session
tmux new-session -d -s <session-name> 'pnpm dev'
# Example with a descriptive name:
tmux new-session -d -s auth-fix-3102 'pnpm dev'
```
### Managing the session
| Task | Command |
|------|---------|
| Check if the session is alive | `tmux has-session -t <session-name> 2>/dev/null && echo running` |
| View server output | `tmux capture-pane -t <session-name> -p` |
| Kill the session | `tmux kill-session -t <session-name>` |
| List all tmux sessions | `tmux list-sessions` |
### Verifying the server is reachable
After launching, confirm the port is listening before reporting success:
```bash
# Wait briefly for startup, then verify
sleep 3
curl -sf http://127.0.0.1:<port>/api/health && echo "Server is up"
lsof -nP -iTCP:<port> -sTCP:LISTEN
```
### Key rules
1. **Always use `tmux` (or equivalent)** when a dev server needs to stay running after the heartbeat ends. A server started directly from the agent shell will die when the heartbeat exits, even if it appeared healthy moments before.
2. **Name the session descriptively** — include the worktree name and port (e.g., `auth-fix-3102`).
3. **Verify the server is listening** before reporting the URL to anyone.
4. **Do not use `nohup` or `&` alone** — these are unreliable for agent shells that may have their entire process group killed.
5. **Clean up when done** — kill the tmux session when the testing is complete.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Server won't start | Run `npx paperclipai doctor --repair` to diagnose and auto-fix |
| Forgetting to source worktree env | Run `eval "$(npx paperclipai worktree env)"` after cd-ing into the worktree |
| Stale dependencies after pull | Run `pnpm install && pnpm build` after pulling |
| Schema out of date after pull | Run `pnpm db:generate && pnpm db:migrate` |
| Reseeding while target DB is running | Stop the target server first, or use `--allow-live-target` |
| Cleaning up with unmerged commits | Merge or push first, or use `--force` if intentionally discarding |
| Running agents against wrong instance | Verify `PAPERCLIP_API_URL` points to the correct port |
| CLI command fails | Do NOT work around it — report the error and block (see Hard Rules above) |
| Agent tries manual postgres operations | NEVER do this — all DB ops go through the CLI (see Hard Rules above) |
| Dev server dies between heartbeats | Launch in a detached `tmux` session — see "Persistent Dev Servers" above |
| Pushed feature branch to `paperclipai/paperclip` when a fork exists | Push to the user's fork remote instead — see "Forks" above |
+7 -2
View File
@@ -36,10 +36,15 @@ export const companySkillsApi = {
`/companies/${encodeURIComponent(companyId)}/skills`,
payload,
),
importFromSource: (companyId: string, source: string) =>
importFromSource: (companyId: string, source: string, authToken?: string) =>
api.post<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
{ source },
{ source, ...(authToken ? { authToken } : {}) },
),
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
api.patch<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
{ authToken },
),
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
api.post<CompanySkillProjectScanResult>(
+131 -20
View File
@@ -1,13 +1,33 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload";
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
vi.mock("../api/issues", () => ({
issuesApi: { get: vi.fn() },
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function withProviders(children: ReactNode) {
return (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
describe("approvalLabel", () => {
it("uses payload titles for generic board approvals", () => {
expect(
@@ -35,17 +55,19 @@ describe("ApprovalPayloadRenderer", () => {
act(() => {
root.render(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
recommendedAction: "Approve the frog reply.",
nextActionOnApproval: "Post the frog comment on the issue.",
risks: ["The frog might be too powerful."],
proposedComment: "(o)<",
}}
/>,
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
recommendedAction: "Approve the frog reply.",
nextActionOnApproval: "Post the frog comment on the issue.",
risks: ["The frog might be too powerful."],
proposedComment: "(o)<",
}}
/>,
),
);
});
@@ -67,14 +89,16 @@ describe("ApprovalPayloadRenderer", () => {
act(() => {
root.render(
<ApprovalPayloadRenderer
type="request_board_approval"
hidePrimaryTitle
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
}}
/>,
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
hidePrimaryTitle
payload={{
title: "Reply with an ASCII frog",
summary: "Board asked for approval before posting the frog.",
}}
/>,
),
);
});
@@ -86,3 +110,90 @@ describe("ApprovalPayloadRenderer", () => {
});
});
});
describe("BoardApprovalPayloadContent markdown rendering", () => {
it("renders a ## header in summary as an h2 element", () => {
const html = renderToStaticMarkup(
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{ summary: "## Analysis\n\nThis is the summary." }}
/>,
),
);
expect(html).toContain("<h2");
expect(html).toContain("Analysis");
expect(html).toContain("This is the summary.");
});
it("renders a bulleted list in summary as ul and li elements", () => {
const html = renderToStaticMarkup(
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{ summary: "- Item one\n- Item two" }}
/>,
),
);
expect(html).toContain("<ul");
expect(html).toContain("<li");
expect(html).toContain("Item one");
expect(html).toContain("Item two");
});
it("renders a ## header in recommendedAction as an h2 element", () => {
const html = renderToStaticMarkup(
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{ recommendedAction: "## Approve\n\nApprove this action." }}
/>,
),
);
expect(html).toContain("<h2");
expect(html).toContain("Approve");
expect(html).toContain("Approve this action.");
});
it("renders a bulleted list in recommendedAction as ul and li elements", () => {
const html = renderToStaticMarkup(
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{ recommendedAction: "- Step one\n- Step two" }}
/>,
),
);
expect(html).toContain("<ul");
expect(html).toContain("<li");
expect(html).toContain("Step one");
});
it("renders plain prose summary without adding list or heading markup", () => {
const html = renderToStaticMarkup(
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{ summary: "This is a simple one-line summary." }}
/>,
),
);
expect(html).toContain("This is a simple one-line summary.");
expect(html).not.toContain("<ul");
expect(html).not.toContain("<h2");
});
it("renders plain prose recommendedAction without markdown markup", () => {
const html = renderToStaticMarkup(
withProviders(
<ApprovalPayloadRenderer
type="request_board_approval"
payload={{ recommendedAction: "Approve the deployment." }}
/>,
),
);
expect(html).toContain("Approve the deployment.");
expect(html).not.toContain("<ul");
expect(html).not.toContain("<h2");
});
});
+4 -3
View File
@@ -1,5 +1,6 @@
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
import { formatCents } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
export const typeLabel: Record<string, string> = {
hire_agent: "Hire Agent",
@@ -185,7 +186,7 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unkn
{summary && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Summary</p>
<p className="leading-6 text-foreground/90">{summary}</p>
<MarkdownBody softBreaks>{summary}</MarkdownBody>
</div>
)}
{recommendedAction && (
@@ -193,13 +194,13 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unkn
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-amber-700 dark:text-amber-300">
Recommended action
</p>
<p className="mt-1 leading-6 text-foreground">{recommendedAction}</p>
<MarkdownBody softBreaks className="mt-1">{recommendedAction}</MarkdownBody>
</div>
)}
{nextActionOnApproval && (
<div className="rounded-lg border border-border/60 bg-background/60 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">On approval</p>
<p className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</p>
<MarkdownBody softBreaks className="mt-1">{nextActionOnApproval}</MarkdownBody>
</div>
)}
{risks.length > 0 && (
+9 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
import { X } from "lucide-react";
import { cn } from "../lib/utils";
import { Textarea } from "./ui/textarea";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@@ -212,8 +213,14 @@ export function EnvVarEditor({
</>
) : (
<>
<input
className={cn(inputClass, "flex-[3]")}
<Textarea
rows={1}
className={cn(
inputClass,
"flex-[3] min-h-0 resize-none shadow-none",
"h-8 overflow-hidden field-sizing-fixed",
"focus:h-auto focus:overflow-auto focus:field-sizing-content",
)}
placeholder="value"
value={row.plainValue}
onChange={(event) => updateRow(index, { plainValue: event.target.value })}
+60
View File
@@ -17,6 +17,15 @@ import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { projectsApi } from "../api/projects";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { MarkdownBody } from "../components/MarkdownBody";
@@ -603,6 +612,8 @@ export function CompanyExport() {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
const [treeSearch, setTreeSearch] = useState("");
const [includeSecrets, setIncludeSecrets] = useState(false);
const [secretsConfirmOpen, setSecretsConfirmOpen] = useState(false);
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const savedExpandedRef = useRef<Set<string> | null>(null);
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
@@ -731,6 +742,7 @@ export function CompanyExport() {
include: { company: true, agents: true, projects: true, issues: true },
selectedFiles: Array.from(checkedFiles).sort(),
sidebarOrder,
includeSecrets,
}),
onSuccess: (result) => {
const resultCheckedFiles = new Set(Object.keys(result.files));
@@ -945,6 +957,11 @@ export function CompanyExport() {
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
</span>
)}
{includeSecrets && (
<span className="rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-0.5 text-xs text-amber-500">
Secrets included
</span>
)}
</div>
<Button
size="sm"
@@ -974,6 +991,29 @@ export function CompanyExport() {
<div className="border-b border-border px-4 py-3 shrink-0">
<h2 className="text-base font-semibold">Package files</h2>
</div>
<div className="border-b border-border px-4 py-2.5 shrink-0">
<div className="flex items-center gap-2 text-sm">
<ToggleSwitch
checked={includeSecrets}
onCheckedChange={(checked) => {
if (checked) {
setSecretsConfirmOpen(true);
} else {
setIncludeSecrets(false);
}
}}
/>
<span className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors" onClick={() => {
if (includeSecrets) {
setIncludeSecrets(false);
} else {
setSecretsConfirmOpen(true);
}
}}>
Include secrets
</span>
</div>
</div>
<div className="border-b border-border px-3 py-2 shrink-0">
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
@@ -1014,6 +1054,26 @@ export function CompanyExport() {
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
</div>
</div>
{/* Secrets confirmation dialog */}
<Dialog open={secretsConfirmOpen} onOpenChange={setSecretsConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Include secrets?</DialogTitle>
<DialogDescription>
Secrets will be exported as plaintext in the package file. Handle the exported package with care.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setSecretsConfirmOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => { setIncludeSecrets(true); setSecretsConfirmOpen(false); }}>
Include secrets
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+19
View File
@@ -866,6 +866,13 @@ export function CompanyImport() {
title: "Import complete",
body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`,
});
if (result.warnings.some((w) => w.includes("could not be decrypted") || w.toLowerCase().includes("failed to create secret"))) {
pushToast({
tone: "warn",
title: "Secrets import warning",
body: "Some secrets could not be decrypted. Review warnings and recreate manually.",
});
}
// Force a fresh dashboard load so newly imported agents are immediately visible.
window.location.assign(`/${importedCompany.issuePrefix}/dashboard`);
},
@@ -1309,6 +1316,18 @@ export function CompanyImport() {
</div>
)}
{/* Secrets info */}
{importPreview.manifest.secrets && importPreview.manifest.secrets.length > 0 && (
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="text-xs font-medium text-amber-500 mb-1">Secrets to import</div>
{importPreview.manifest.secrets.map((s) => (
<div key={s.name} className="text-xs text-amber-500">
{s.name}{s.provider !== "local_encrypted" ? ` (${s.provider})` : ""}
</div>
))}
</div>
)}
{/* Errors */}
{importPreview.errors.length > 0 && (
<div className="mx-5 mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3">
+123 -2
View File
@@ -52,6 +52,7 @@ import {
RefreshCw,
Save,
Search,
ShieldCheck,
Trash2,
} from "lucide-react";
@@ -487,6 +488,103 @@ function SkillList({
);
}
function SkillAuthSection({
companyId,
skillId,
hasAuth,
}: {
companyId: string;
skillId: string;
hasAuth: boolean;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [editing, setEditing] = useState(false);
const [token, setToken] = useState("");
const updateAuth = useMutation({
mutationFn: (authToken: string | null) =>
companySkillsApi.updateAuth(companyId, skillId, authToken),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) });
setEditing(false);
setToken("");
pushToast({ tone: "success", title: "Auth updated" });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Failed to update auth",
body: error instanceof Error ? error.message : "Unknown error",
});
},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
{!editing ? (
<>
{hasAuth ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
PAT configured
</Button>
<button
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
onClick={() => updateAuth.mutate(null)}
disabled={updateAuth.isPending}
title="Remove PAT"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
Add PAT
</Button>
)}
</>
) : (
<>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="GitHub Personal Access Token"
className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
autoComplete="off"
autoFocus
/>
<Button
size="sm"
onClick={() => updateAuth.mutate(token.trim())}
disabled={!token.trim() || updateAuth.isPending}
>
{updateAuth.isPending ? "Saving..." : "Save"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditing(false); setToken(""); }}
>
Cancel
</Button>
</>
)}
</div>
);
}
function SkillPane({
loading,
detail,
@@ -614,6 +712,13 @@ function SkillPane({
)}
</span>
</div>
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
<SkillAuthSection
companyId={detail.companyId}
skillId={detail.id}
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
/>
)}
{detail.sourceType === "github" && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
@@ -762,6 +867,7 @@ export function CompanySkills() {
const { pushToast } = useToastActions();
const [skillFilter, setSkillFilter] = useState("");
const [source, setSource] = useState("");
const [importAuthToken, setImportAuthToken] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
@@ -887,7 +993,8 @@ export function CompanySkills() {
}
const importSkill = useMutation({
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) =>
companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
@@ -900,6 +1007,7 @@ export function CompanySkills() {
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
}
setSource("");
setImportAuthToken("");
},
onError: (error) => {
pushToast({
@@ -1073,7 +1181,8 @@ export function CompanySkills() {
setEmptySourceHelpOpen(true);
return;
}
importSkill.mutate(trimmedSource);
const token = importAuthToken.trim() || undefined;
importSkill.mutate({ importSource: trimmedSource, authToken: token });
}
return (
@@ -1220,6 +1329,18 @@ export function CompanySkills() {
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
</Button>
</div>
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
<input
type="password"
value={importAuthToken}
onChange={(event) => setImportAuthToken(event.target.value)}
placeholder="GitHub PAT (optional, for private repos)"
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoComplete="off"
/>
</div>
)}
{scanStatusMessage && (
<p className="mt-3 text-xs text-muted-foreground">
{scanStatusMessage}