Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c5f316f87 | |||
| 3620aaaad1 | |||
| 548d958f18 | |||
| 562693197a | |||
| bedeac5400 | |||
| 06abed51b7 | |||
| dcdc1d2353 | |||
| b172b6a319 | |||
| 39790922f1 | |||
| ebc249d0b3 | |||
| 5499a0b4a6 | |||
| 55faea456f | |||
| 329ba3fd2e | |||
| bf251188df | |||
| 80f7d8270c | |||
| 5703fa225c | |||
| 4317d2a3b4 | |||
| d30afdb1b2 | |||
| 0fd4e9c4d1 | |||
| 8dbe99e32e | |||
| 818a8eade8 | |||
| 9e854e33d9 | |||
| 26e814a426 | |||
| fccbc7e39e | |||
| 729ef021e9 | |||
| 9b275c332a | |||
| 9035b70aa9 | |||
| 1eccb71213 | |||
| f8b8303089 | |||
| 3e998bda97 | |||
| 40e8638aa3 | |||
| 713fb6eb4e | |||
| 58d1b19206 | |||
| fcbbd50b60 | |||
| a6c2e0392b | |||
| a98c5cdfa9 | |||
| 94fc81266f | |||
| b248acd46c | |||
| c37e5919ce | |||
| 45621aac53 | |||
| 39d81c732c | |||
| e691d30d12 | |||
| 163e3ca1a5 | |||
| 55d6c5bfa4 | |||
| b6b81f2f06 | |||
| 4c4eeaba2b | |||
| 7a8afbb719 | |||
| b61455373c | |||
| 73f4685729 | |||
| 7cee02ddf3 | |||
| 417782a6ec | |||
| 30ef61bb25 | |||
| 872dd664ed | |||
| d9d9bbcf06 | |||
| c2a49879d5 | |||
| 5d0d076704 | |||
| 08dc3d9ff4 | |||
| 37e0aac971 | |||
| cee1cd7f4e | |||
| b3e7dcaa83 | |||
| 3ea3020a76 | |||
| 27db0d3c67 | |||
| 5c681340f3 | |||
| 85cbbc9263 | |||
| acbfcb7d00 | |||
| 5f3cd831a1 | |||
| 6cb333b986 | |||
| 18f550b946 | |||
| 191491a57f | |||
| 3bbd632355 | |||
| 7e2517935c | |||
| 441bbd5b9a | |||
| bf1abb1492 | |||
| e37180d3e3 | |||
| 1678160c49 | |||
| c08c72e917 | |||
| fe43fbe2fd | |||
| 6bbe51ca4d | |||
| 2131ede7b8 | |||
| e8579d5c66 | |||
| 9e30b72b27 | |||
| 7b12d907cc | |||
| d1d592d793 | |||
| 3dfb859676 |
+77
@@ -0,0 +1,77 @@
|
||||
name: "Build: Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
image-tag: ${{ steps.tag.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image tag
|
||||
id: tag
|
||||
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: admin
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.farh.net/farhoodlabs/paperclip-dev
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: .farhoodlabs/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
|
||||
update-infra:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update dev image tag in infra repo
|
||||
run: |
|
||||
SHA="${{ needs.build.outputs.image-tag }}"
|
||||
FILE="overlays/dev/kustomization.yaml"
|
||||
|
||||
response=$(curl -sS \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE")
|
||||
|
||||
file_sha=$(echo "$response" | jq -r '.sha')
|
||||
content=$(echo "$response" | jq -r '.content' | base64 -d)
|
||||
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
|
||||
encoded=$(printf '%s' "$new_content" | base64 -w 0)
|
||||
|
||||
curl -sS -X PUT \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
||||
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
name: "Build: Production"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [local]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: admin
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.farh.net/farhoodlabs/paperclip
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: .farhoodlabs/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
@@ -0,0 +1,79 @@
|
||||
# Paperclip Fork — Project Context
|
||||
|
||||
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
||||
Fork repo: https://git.farh.net/farhoodlabs/paperclip
|
||||
|
||||
## Branch Model
|
||||
|
||||
| Branch | Purpose |
|
||||
|---|---|
|
||||
| `master` | Pure mirror of `upstream/master`. No fork-specific files in the tree. Sync via `git push origin upstream/master:master --force-with-lease`. |
|
||||
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` (defined on `dev`) on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `git.farh.net/farhoodlabs/paperclip`. |
|
||||
| `dev` | Development branch and canonical home of the `.farhoodlabs/` overlay and the `assemble-local.yml` action. Based on upstream/master. Builds `git.farh.net/farhoodlabs/paperclip-dev` on every push. |
|
||||
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
|
||||
|
||||
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
|
||||
|
||||
## Fork Overlay (`.farhoodlabs/`)
|
||||
|
||||
Files committed to `.farhoodlabs/` on `dev` (the canonical source) 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 `dev` and push. The assemble action will apply them to `local` on the next `master` push (or trigger it manually via `workflow_dispatch`).
|
||||
|
||||
## Pending Upstream PRs (included in `local`)
|
||||
|
||||
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
|
||||
|
||||
| PR | Branch | Method | Notes |
|
||||
|---|---|---|---|
|
||||
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
|
||||
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
|
||||
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Sync upstream into master
|
||||
```bash
|
||||
git fetch upstream
|
||||
git push origin upstream/master:master --force-with-lease
|
||||
# assemble-local.yml triggers automatically and rebuilds local
|
||||
```
|
||||
|
||||
### Add a new pending PR to local
|
||||
Edit `.github/workflows/assemble-local.yml` on `dev`:
|
||||
- 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 `dev`.
|
||||
|
||||
### 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 `dev`. 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 `git.farh.net/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 `dev` (canonical) and are also kept in sync on `local`
|
||||
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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/skills-catalog/package.json packages/skills-catalog/
|
||||
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
||||
COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
|
||||
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
|
||||
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
|
||||
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
|
||||
COPY patches/ patches/
|
||||
COPY scripts/link-plugin-dev-sdk.mjs scripts/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm --filter @paperclipai/ui build
|
||||
RUN pnpm --filter @paperclipai/plugin-sdk build
|
||||
RUN pnpm --filter @paperclipai/server build
|
||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
||||
|
||||
FROM base AS production
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools, mmx-cli
|
||||
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
|
||||
&& chmod +x /usr/local/bin/kubectl \
|
||||
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
|
||||
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
|
||||
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& mv /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& curl -fsSL https://codeberg.org/forgejo-contrib/forgejo-cli/releases/download/v0.4.1/forgejo-cli-linux.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/fj \
|
||||
&& curl -fsSL https://github.com/JKamsker/forgejo-cli-ex/releases/download/v0.1.7/fj-ex-linux-x86_64.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/fj-ex \
|
||||
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
|
||||
&& chmod +x /usr/local/bin/fgj \
|
||||
&& curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \
|
||||
&& chmod +x /usr/local/bin/tea \
|
||||
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& npm install --global --omit=dev mmx-cli \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,77 @@
|
||||
name: "Build: Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
image-tag: ${{ steps.tag.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image tag
|
||||
id: tag
|
||||
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: admin
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.farh.net/farhoodlabs/paperclip-dev
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: .farhoodlabs/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
|
||||
update-infra:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update dev image tag in infra repo
|
||||
run: |
|
||||
SHA="${{ needs.build.outputs.image-tag }}"
|
||||
FILE="overlays/dev/kustomization.yaml"
|
||||
|
||||
response=$(curl -sS \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE")
|
||||
|
||||
file_sha=$(echo "$response" | jq -r '.sha')
|
||||
content=$(echo "$response" | jq -r '.content' | base64 -d)
|
||||
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
|
||||
encoded=$(printf '%s' "$new_content" | base64 -w 0)
|
||||
|
||||
curl -sS -X PUT \
|
||||
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
||||
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
||||
@@ -0,0 +1,48 @@
|
||||
name: "Build: Production"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [local]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.farh.net
|
||||
username: admin
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.farh.net/farhoodlabs/paperclip
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: .farhoodlabs/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
@@ -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: 60
|
||||
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."
|
||||
|
||||
@@ -1,96 +1,16 @@
|
||||
# Disabled in fork — `gh` CLI and GitHub-specific commands are not available on Gitea.
|
||||
# Lockfile refreshes are managed directly in development workflows.
|
||||
#
|
||||
# NOTE: upstream may overwrite this file when master is synced. Re-apply if that happens.
|
||||
name: Refresh Lockfile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: refresh-lockfile-master
|
||||
cancel-in-progress: false
|
||||
|
||||
inputs:
|
||||
note:
|
||||
description: "Disabled in fork. Uses GitHub-specific gh CLI."
|
||||
required: false
|
||||
jobs:
|
||||
refresh:
|
||||
disabled:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Refresh pnpm lockfile
|
||||
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Fail on unexpected file changes
|
||||
run: |
|
||||
changed="$(git status --porcelain)"
|
||||
if [ -z "$changed" ]; then
|
||||
echo "Lockfile is already up to date."
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
||||
echo "Unexpected files changed during lockfile refresh:"
|
||||
echo "$changed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create or update pull request
|
||||
id: upsert-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
echo "pr_url=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="chore/refresh-lockfile"
|
||||
git config user.name "lockfile-bot"
|
||||
git config user.email "lockfile-bot@users.noreply.github.com"
|
||||
|
||||
git checkout -B "$BRANCH"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
||||
git push --force origin "$BRANCH"
|
||||
|
||||
# Only reuse an open PR from this repository owner, not a fork with the same branch name.
|
||||
pr_url="$(
|
||||
gh pr list --state open --head "$BRANCH" --json url,headRepositoryOwner \
|
||||
--jq ".[] | select(.headRepositoryOwner.login == \"$REPO_OWNER\") | .url" |
|
||||
head -n 1
|
||||
)"
|
||||
if [ -z "$pr_url" ]; then
|
||||
pr_url="$(gh pr create \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(lockfile): refresh pnpm-lock.yaml" \
|
||||
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml.")"
|
||||
echo "Created new PR: $pr_url"
|
||||
else
|
||||
echo "PR already exists: $pr_url"
|
||||
fi
|
||||
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable auto-merge for lockfile PR
|
||||
if: steps.upsert-pr.outputs.pr_url != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}"
|
||||
- run: echo "Disabled. Lockfile management requires GitHub-specific tooling."
|
||||
|
||||
@@ -1,273 +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: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- 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: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- 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: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- 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: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- 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."
|
||||
|
||||
@@ -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: ubuntu-latest
|
||||
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
|
||||
@@ -0,0 +1,79 @@
|
||||
# Paperclip Fork — Project Context
|
||||
|
||||
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
||||
Fork repo: https://git.farh.net/farhoodlabs/paperclip
|
||||
|
||||
## Branch Model
|
||||
|
||||
| Branch | Purpose |
|
||||
|---|---|
|
||||
| `master` | Pure mirror of `upstream/master`. No fork-specific files in the tree. Sync via `git push origin upstream/master:master --force-with-lease`. |
|
||||
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` (defined on `dev`) on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `git.farh.net/farhoodlabs/paperclip`. |
|
||||
| `dev` | Development branch and canonical home of the `.farhoodlabs/` overlay and the `assemble-local.yml` action. Based on upstream/master. Builds `git.farh.net/farhoodlabs/paperclip-dev` on every push. |
|
||||
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
|
||||
|
||||
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
|
||||
|
||||
## Fork Overlay (`.farhoodlabs/`)
|
||||
|
||||
Files committed to `.farhoodlabs/` on `dev` (the canonical source) 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 `dev` and push. The assemble action will apply them to `local` on the next `master` push (or trigger it manually via `workflow_dispatch`).
|
||||
|
||||
## Pending Upstream PRs (included in `local`)
|
||||
|
||||
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
|
||||
|
||||
| PR | Branch | Method | Notes |
|
||||
|---|---|---|---|
|
||||
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
|
||||
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
|
||||
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Sync upstream into master
|
||||
```bash
|
||||
git fetch upstream
|
||||
git push origin upstream/master:master --force-with-lease
|
||||
# assemble-local.yml triggers automatically and rebuilds local
|
||||
```
|
||||
|
||||
### Add a new pending PR to local
|
||||
Edit `.github/workflows/assemble-local.yml` on `dev`:
|
||||
- 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 `dev`.
|
||||
|
||||
### 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 `dev`. 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 `git.farh.net/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 `dev` (canonical) and are also kept in sync on `local`
|
||||
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
|
||||
@@ -22,6 +22,7 @@ COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||
COPY packages/skills-catalog/package.json packages/skills-catalog/
|
||||
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface PaperclipSkillEntry {
|
||||
key: string;
|
||||
runtimeName: string;
|
||||
source: string;
|
||||
sourceStatus?: "available" | "missing";
|
||||
missingDetail?: string | null;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
}
|
||||
@@ -161,6 +163,22 @@ interface PersistentSkillSnapshotOptions {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface RuntimeMountedSkillSnapshotOptions {
|
||||
adapterType: string;
|
||||
availableEntries: PaperclipSkillEntry[];
|
||||
desiredSkills: string[];
|
||||
configuredDetail: string | ((entry: PaperclipSkillEntry) => string | null);
|
||||
missingDetail?: string;
|
||||
mode?: "ephemeral" | "unsupported";
|
||||
supported?: boolean;
|
||||
unsupportedDetail?: string | ((entry: PaperclipSkillEntry) => string | null);
|
||||
warnings?: string[];
|
||||
externalInstalled?: Map<string, InstalledSkillTarget>;
|
||||
externalLocationLabel?: string | null;
|
||||
externalDetail?: string;
|
||||
skillsHome?: string;
|
||||
}
|
||||
|
||||
function normalizePathSlashes(value: string): string {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
@@ -193,6 +211,26 @@ function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
function isPaperclipSkillSourceMissing(entry: PaperclipSkillEntry) {
|
||||
return entry.sourceStatus === "missing";
|
||||
}
|
||||
|
||||
function resolvePaperclipSkillMissingDetail(
|
||||
entry: PaperclipSkillEntry,
|
||||
fallback: string,
|
||||
) {
|
||||
return entry.missingDetail?.trim() || fallback;
|
||||
}
|
||||
|
||||
function resolveSkillDetail(
|
||||
detail: string | ((entry: PaperclipSkillEntry) => string | null) | null | undefined,
|
||||
entry: PaperclipSkillEntry,
|
||||
): string | null {
|
||||
if (typeof detail === "function") return detail(entry);
|
||||
if (typeof detail === "string") return detail;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveInstalledEntryTarget(
|
||||
skillsHome: string,
|
||||
entryName: string,
|
||||
@@ -1381,6 +1419,120 @@ export async function readInstalledSkillTargets(skillsHome: string): Promise<Map
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildRuntimeMountedSkillSnapshot(
|
||||
options: RuntimeMountedSkillSnapshotOptions,
|
||||
): AdapterSkillSnapshot {
|
||||
const {
|
||||
adapterType,
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
configuredDetail,
|
||||
missingDetail = "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
mode = "ephemeral",
|
||||
externalInstalled,
|
||||
externalLocationLabel,
|
||||
externalDetail = "Installed outside Paperclip management.",
|
||||
skillsHome,
|
||||
} = options;
|
||||
const supported = options.supported ?? mode !== "unsupported";
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings = [...(options.warnings ?? [])];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const desired = desiredSet.has(available.key);
|
||||
if (isPaperclipSkillSourceMissing(available)) {
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const configured = supported && mode === "ephemeral" && desired;
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: configured ? "configured" : "available",
|
||||
sourcePath: available.source,
|
||||
targetPath: null,
|
||||
detail: desired
|
||||
? configured
|
||||
? resolveSkillDetail(configuredDetail, available)
|
||||
: resolveSkillDetail(
|
||||
options.unsupportedDetail
|
||||
?? "Desired state is stored in Paperclip only; this adapter cannot apply skills at runtime.",
|
||||
available,
|
||||
)
|
||||
: null,
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: missingDetail,
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (externalInstalled) {
|
||||
for (const [name, installedEntry] of externalInstalled.entries()) {
|
||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||
entries.push({
|
||||
key: name,
|
||||
runtimeName: name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: skillLocationLabel(externalLocationLabel),
|
||||
readOnly: true,
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? (skillsHome ? path.join(skillsHome, name) : null),
|
||||
detail: externalDetail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
adapterType,
|
||||
supported,
|
||||
mode,
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPersistentSkillSnapshot(
|
||||
options: PersistentSkillSnapshotOptions,
|
||||
): AdapterSkillSnapshot {
|
||||
@@ -1404,6 +1556,26 @@ export function buildPersistentSkillSnapshot(
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||
const desired = desiredSet.has(available.key);
|
||||
if (isPaperclipSkillSourceMissing(available)) {
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: path.join(skillsHome, available.runtimeName),
|
||||
detail: resolvePaperclipSkillMissingDetail(
|
||||
available,
|
||||
missingDetail,
|
||||
),
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
@@ -1496,6 +1668,11 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
|
||||
key,
|
||||
runtimeName,
|
||||
source,
|
||||
sourceStatus: entry.sourceStatus === "missing" ? "missing" : "available",
|
||||
missingDetail:
|
||||
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
|
||||
? entry.missingDetail.trim()
|
||||
: null,
|
||||
required: asBoolean(entry.required, false),
|
||||
requiredReason:
|
||||
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
CREATE TABLE IF NOT EXISTS "document_annotation_threads" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"document_key" text NOT NULL,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"anchor_state" text DEFAULT 'active' NOT NULL,
|
||||
"original_revision_id" uuid,
|
||||
"original_revision_number" integer NOT NULL,
|
||||
"current_revision_id" uuid,
|
||||
"current_revision_number" integer NOT NULL,
|
||||
"selected_text" text NOT NULL,
|
||||
"prefix_text" text DEFAULT '' NOT NULL,
|
||||
"suffix_text" text DEFAULT '' NOT NULL,
|
||||
"normalized_start" integer NOT NULL,
|
||||
"normalized_end" integer NOT NULL,
|
||||
"markdown_start" integer NOT NULL,
|
||||
"markdown_end" integer NOT NULL,
|
||||
"anchor_confidence" text DEFAULT 'exact' NOT NULL,
|
||||
"anchor_selector" jsonb NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"resolved_by_agent_id" uuid,
|
||||
"resolved_by_user_id" text,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "document_annotation_comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"thread_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"author_type" text NOT NULL,
|
||||
"author_agent_id" uuid,
|
||||
"author_user_id" text,
|
||||
"created_by_run_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "document_annotation_anchor_snapshots" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"thread_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"from_revision_id" uuid,
|
||||
"from_revision_number" integer,
|
||||
"to_revision_id" uuid,
|
||||
"to_revision_number" integer NOT NULL,
|
||||
"previous_anchor" jsonb NOT NULL,
|
||||
"next_anchor" jsonb,
|
||||
"anchor_state" text NOT NULL,
|
||||
"anchor_confidence" text NOT NULL,
|
||||
"failure_reason" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_document_id_documents_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_original_revision_id_document_revisions_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_original_revision_id_document_revisions_id_fk" FOREIGN KEY ("original_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_current_revision_id_document_revisions_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_current_revision_id_document_revisions_id_fk" FOREIGN KEY ("current_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_created_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_resolved_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_thread_id_document_annotation_threads_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_document_id_documents_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_author_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_document_id_documents_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk" FOREIGN KEY ("from_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk') THEN
|
||||
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk" FOREIGN KEY ("to_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_document_status_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_issue_status_idx" ON "document_annotation_threads" USING btree ("company_id","issue_id","status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_current_revision_open_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","current_revision_id","status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_anchor_state_idx" ON "document_annotation_threads" USING btree ("company_id","anchor_state");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_thread_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","thread_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_issue_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","issue_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_document_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","document_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_comments_body_search_idx" ON "document_annotation_comments" USING gin ("body" gin_trgm_ops);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_thread_created_at_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","thread_id","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_document_revision_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","document_id","to_revision_number");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -638,6 +638,13 @@
|
||||
"when": 1779573019125,
|
||||
"tag": "0090_resource_memberships",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 91,
|
||||
"version": "7",
|
||||
"when": 1778810394522,
|
||||
"tag": "0091_old_swarm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
DocumentAnnotationAnchorConfidence,
|
||||
DocumentAnnotationAnchorSnapshot,
|
||||
DocumentAnnotationAnchorState,
|
||||
} from "@paperclipai/shared";
|
||||
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||
import { documentRevisions } from "./document_revisions.js";
|
||||
import { documents } from "./documents.js";
|
||||
|
||||
export const documentAnnotationAnchorSnapshots = pgTable(
|
||||
"document_annotation_anchor_snapshots",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
fromRevisionId: uuid("from_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||
fromRevisionNumber: integer("from_revision_number"),
|
||||
toRevisionId: uuid("to_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||
toRevisionNumber: integer("to_revision_number").notNull(),
|
||||
previousAnchor: jsonb("previous_anchor").$type<DocumentAnnotationAnchorSnapshot>().notNull(),
|
||||
nextAnchor: jsonb("next_anchor").$type<DocumentAnnotationAnchorSnapshot | null>(),
|
||||
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull(),
|
||||
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().notNull(),
|
||||
failureReason: text("failure_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyThreadCreatedAtIdx: index("document_annotation_anchor_snapshots_company_thread_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.threadId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyDocumentRevisionIdx: index("document_annotation_anchor_snapshots_company_document_revision_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
table.toRevisionNumber,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { IssueCommentAuthorType } from "@paperclipai/shared";
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||
import { documents } from "./documents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const documentAnnotationComments = pgTable(
|
||||
"document_annotation_comments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
body: text("body").notNull(),
|
||||
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
|
||||
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
authorUserId: text("author_user_id"),
|
||||
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyThreadCreatedAtIdx: index("document_annotation_comments_company_thread_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.threadId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyIssueCreatedAtIdx: index("document_annotation_comments_company_issue_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
table.createdAt,
|
||||
),
|
||||
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
DocumentAnnotationAnchorConfidence,
|
||||
DocumentAnnotationAnchorSelector,
|
||||
DocumentAnnotationAnchorState,
|
||||
DocumentAnnotationThreadStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { documentRevisions } from "./document_revisions.js";
|
||||
import { documents } from "./documents.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const documentAnnotationThreads = pgTable(
|
||||
"document_annotation_threads",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
documentKey: text("document_key").notNull(),
|
||||
status: text("status").$type<DocumentAnnotationThreadStatus>().notNull().default("open"),
|
||||
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull().default("active"),
|
||||
originalRevisionId: uuid("original_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||
originalRevisionNumber: integer("original_revision_number").notNull(),
|
||||
currentRevisionId: uuid("current_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
|
||||
currentRevisionNumber: integer("current_revision_number").notNull(),
|
||||
selectedText: text("selected_text").notNull(),
|
||||
prefixText: text("prefix_text").notNull().default(""),
|
||||
suffixText: text("suffix_text").notNull().default(""),
|
||||
normalizedStart: integer("normalized_start").notNull(),
|
||||
normalizedEnd: integer("normalized_end").notNull(),
|
||||
markdownStart: integer("markdown_start").notNull(),
|
||||
markdownEnd: integer("markdown_end").notNull(),
|
||||
anchorConfidence: text("anchor_confidence")
|
||||
.$type<DocumentAnnotationAnchorConfidence>()
|
||||
.notNull()
|
||||
.default("exact"),
|
||||
anchorSelector: jsonb("anchor_selector").$type<DocumentAnnotationAnchorSelector>().notNull(),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
resolvedByUserId: text("resolved_by_user_id"),
|
||||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyDocumentStatusIdx: index("document_annotation_threads_company_document_status_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
table.status,
|
||||
),
|
||||
companyIssueStatusIdx: index("document_annotation_threads_company_issue_status_idx").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.status,
|
||||
),
|
||||
companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
table.currentRevisionId,
|
||||
table.status,
|
||||
),
|
||||
companyAnchorStateIdx: index("document_annotation_threads_company_anchor_state_idx").on(
|
||||
table.companyId,
|
||||
table.anchorState,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -55,6 +55,9 @@ export { issueAttachments } from "./issue_attachments.js";
|
||||
export { documents } from "./documents.js";
|
||||
export { documentRevisions } from "./document_revisions.js";
|
||||
export { issueDocuments } from "./issue_documents.js";
|
||||
export { documentAnnotationThreads } from "./document_annotation_threads.js";
|
||||
export { documentAnnotationComments } from "./document_annotation_comments.js";
|
||||
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# @paperclipai/plugin-kubernetes (alpha)
|
||||
|
||||
First-party Paperclip sandbox-provider plugin for Kubernetes.
|
||||
|
||||
**Alpha:** the default backend (`sandbox-cr`) is built on `kubernetes-sigs/agent-sandbox` v1alpha1 — expect breaking changes as that CRD evolves toward Beta. A stable fallback backend (`job`, using `batch/v1` Job) is available for clusters without agent-sandbox installed, but it does NOT support multi-command exec (paperclip-server's adapter-install pattern requires sandbox-cr).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### For `sandbox-cr` backend (default, recommended)
|
||||
|
||||
1. A Kubernetes cluster running k8s 1.27+
|
||||
2. [`kubernetes-sigs/agent-sandbox`](https://github.com/kubernetes-sigs/agent-sandbox) controller installed in the cluster (alpha — installs the `sandboxes.agents.x-k8s.io/v1alpha1` CRD and controller)
|
||||
3. Paperclip-server running with access to the cluster (in-cluster via `inCluster: true` or external via `kubeconfig`)
|
||||
|
||||
### For `job` backend (stable fallback)
|
||||
|
||||
1. A Kubernetes cluster running k8s 1.27+
|
||||
2. Paperclip-server with cluster access — no additional controllers or CRDs required
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
paperclipai plugin install @paperclipai/plugin-kubernetes
|
||||
```
|
||||
|
||||
Or, for local development:
|
||||
|
||||
```bash
|
||||
paperclipai plugin install --local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
The plugin supports two backend modes, selected via the `backend` config field:
|
||||
|
||||
| Backend | Default | Stability | Multi-command exec | Requires |
|
||||
|---|---|---|---|---|
|
||||
| `sandbox-cr` | Yes | Alpha | Yes | `kubernetes-sigs/agent-sandbox` controller |
|
||||
| `job` | No | Stable | No | Nothing beyond k8s 1.27+ |
|
||||
|
||||
**`sandbox-cr` (default):** Creates a `Sandbox` CR (`agents.x-k8s.io/v1alpha1`) whose controller provisions a long-lived pod running `sleep infinity`. paperclip-server execs individual commands into the running pod — this is the multi-command adapter-install pattern. When you `releaseLease`, the Sandbox CR is deleted and the controller tears down the pod.
|
||||
|
||||
**`job` (stable fallback):** Creates a `batch/v1` Job. The container entrypoint runs once and exits — no multi-command exec possible. Use this when you cannot install agent-sandbox, or when you need strictly stable Kubernetes APIs. Note: paperclip-server's adapter-install pattern will not work in job mode.
|
||||
|
||||
### Migrating from `job` to `sandbox-cr`
|
||||
|
||||
1. Install the agent-sandbox controller: `kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/install.yaml`
|
||||
2. Update your environment config to set `backend: "sandbox-cr"` (or remove `backend` since `sandbox-cr` is the default)
|
||||
3. New leases will use the Sandbox CR backend. Existing leases created with `job` mode continue to use job semantics until they are released.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `sandbox` environment with `driver: kubernetes`. One of these auth fields is required:
|
||||
|
||||
- `inCluster: true` — use the in-pod ServiceAccount credentials (when paperclip-server runs inside the same cluster).
|
||||
- `kubeconfig: <YAML>` — inline kubeconfig (stored as a company secret).
|
||||
- `kubeconfigSecretRef: <secret-uuid>` — reference to an existing Paperclip secret.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `backend` | `"sandbox-cr"` | `sandbox-cr` (alpha, requires agent-sandbox controller) or `job` (stable, one-shot entrypoint). |
|
||||
| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, acpx_local, pi_local). Determines runtime image + env keys + egress allow-list. |
|
||||
| `namespacePrefix` | `"paperclip-"` | Prefix for the per-company tenant namespace. |
|
||||
| `paperclipServerNamespace` | `"paperclip"` | Namespace where paperclip-server pods run. Generated egress policies use this so agent pods can call back to the server. |
|
||||
| `companySlug` | derived from companyId | Override the auto-derived company slug. |
|
||||
| `imageRegistry` | (none) | Override the default registry for agent runtime images. |
|
||||
| `imageAllowList` | `[]` | Glob patterns of allowed `target.imageOverride` values. Empty = no override permitted. |
|
||||
| `imagePullSecrets` | `[]` | Names of pre-created Docker image pull secrets in the tenant namespace. |
|
||||
| `egressAllowFqdns` | `[]` | Additional FQDNs (beyond adapter defaults like `api.anthropic.com`). |
|
||||
| `egressAllowCidrs` | `[]` | Additional CIDRs to allow HTTPS egress to. CIDR egress is restricted to TCP port 443. |
|
||||
| `egressMode` | `"standard"` | `standard` (NetworkPolicy + CIDRs, plus public HTTPS fallback when adapter FQDNs are configured) or `cilium` (CiliumNetworkPolicy + exact FQDN allow-list). |
|
||||
| `runtimeClassName` | (none) | e.g. `kata-fc` for Firecracker-backed microVMs. Cluster must have the RuntimeClass installed. |
|
||||
| `serviceAccountAnnotations` | `{}` | Annotations applied to per-tenant ServiceAccount (e.g. IRSA `eks.amazonaws.com/role-arn`). |
|
||||
| `jobTtlSecondsAfterFinished` | `900` | Seconds after a Job completes before garbage-collection. |
|
||||
| `podActivityDeadlineSec` | `3600` | Hard ceiling on a single run's wall-clock time. |
|
||||
|
||||
Full JSON Schema in `src/manifest.ts`.
|
||||
|
||||
## What gets created in your cluster
|
||||
|
||||
For each company that runs agents (created lazily on first dispatch):
|
||||
|
||||
```
|
||||
Namespace paperclip-{companySlug} (PSS: restricted enforce + audit)
|
||||
ServiceAccount paperclip-tenant-sa
|
||||
Role paperclip-tenant-role (only get pods/log)
|
||||
RoleBinding paperclip-tenant-rb
|
||||
ResourceQuota paperclip-quota (pods, requests/limits cpu+memory)
|
||||
LimitRange paperclip-limits (container max/min/default/defaultRequest)
|
||||
NetworkPolicy paperclip-deny-all (deny ingress + egress baseline)
|
||||
NetworkPolicy paperclip-egress-allow (DNS + paperclip-server callback + user CIDRs + public HTTPS fallback for adapter FQDNs)
|
||||
OR CiliumNetworkPolicy paperclip-egress-fqdn if egressMode=cilium
|
||||
```
|
||||
|
||||
Standard Kubernetes NetworkPolicy cannot match FQDNs. In `egressMode: "standard"`, adapter-default FQDNs such as `api.anthropic.com` trigger a public IPv4 HTTPS fallback that excludes private and link-local ranges, so default agent runs can reach model APIs without opening intra-cluster/private-network egress. Use `egressMode: "cilium"` when you need exact FQDN enforcement.
|
||||
|
||||
For each agent run (sandbox-cr backend):
|
||||
|
||||
```
|
||||
Sandbox CR pc-{ulid} (agents.x-k8s.io/v1alpha1; explicit delete on release)
|
||||
Pod pc-{ulid}-{podSuffix} (managed by Sandbox controller; torn down on CR delete)
|
||||
Secret pc-{ulid}-env (owned by Sandbox CR; cascade-deleted)
|
||||
```
|
||||
|
||||
## Fast workspace uploads
|
||||
|
||||
The `sandbox-cr` backend recognizes the chunked base64 upload protocol emitted by `@paperclipai/adapter-utils` for workspace, skill, and config-seed file transfers. Instead of running one Kubernetes exec per base64 chunk, the plugin buffers the upload in worker memory and flushes the final payload through a single `head -c <bytes> | base64 -d` exec with stdin.
|
||||
|
||||
The interceptor is intentionally narrow: only the exact `mkdir`/`printf`/`base64 -d` command shape generated by adapter-utils is optimized. Unknown commands and missing init state fall back to normal exec behavior. Uploads over the 100 MB buffer cap fail fast instead of falling back, because earlier chunks were already acknowledged without being written to the pod.
|
||||
|
||||
For each agent run (job backend):
|
||||
|
||||
```
|
||||
Job pc-{ulid} (backoffLimit: 0, ttlSecondsAfterFinished from config)
|
||||
Pod pc-{ulid}-{podSuffix} (owned by Job; cascade-deleted)
|
||||
Secret pc-{ulid}-env (owned by Job; cascade-deleted)
|
||||
```
|
||||
|
||||
## Security baseline
|
||||
|
||||
Every agent pod is:
|
||||
|
||||
- non-root (`runAsUser: 1000`, `runAsGroup: 1000`, `runAsNonRoot: true`)
|
||||
- drops ALL Linux capabilities, `allowPrivilegeEscalation: false`
|
||||
- `readOnlyRootFilesystem: true` with explicit `emptyDir` mounts for `/workspace`, `/home/paperclip`, `/home/paperclip/.cache`, `/tmp`
|
||||
- `seccompProfile: RuntimeDefault`
|
||||
- Tini as PID 1 (reaps zombies, forwards signals)
|
||||
- `fsGroupChangePolicy: OnRootMismatch` (fast PVC startup; openclaw-operator lesson)
|
||||
- `automountServiceAccountToken: false`
|
||||
|
||||
Plus per-namespace `pod-security.kubernetes.io/enforce: restricted` and a deny-all NetworkPolicy baseline with explicit egress allow-list (DNS, paperclip-server, CIDRs, and either Cilium FQDN rules or standard-mode public HTTPS fallback).
|
||||
|
||||
The per-run Secret carrying the bootstrap token and adapter API keys has `ownerReferences` pointing at the owning Sandbox CR or Job, so releasing the lease cascades cleanly to the Pod and Secret.
|
||||
|
||||
## Optional Kata-FC microVM isolation
|
||||
|
||||
For stronger isolation, install [Kata Containers](https://github.com/kata-containers/kata-containers) with the Firecracker hypervisor, then set `runtimeClassName: kata-fc` in the plugin config. Each agent pod will run inside a Firecracker microVM. Requires nested-virt-capable nodes (bare-metal or specific cloud instance types).
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Phase A (done):** `sandbox-cr` backend — multi-command exec via agent-sandbox Sandbox CRD.
|
||||
- **Phase B:** Warm pool support — pre-provisioned Sandbox CRs for sub-second cold starts. The `SandboxOrchestrator` interface reserves optional `pause?`/`resume?` extension slots.
|
||||
- **Phase C:** Kata-FC + snapshots — `runtimeClassName: kata-fc` with VM snapshot for fast restore.
|
||||
- **Phase D:** Contribute back to agent-sandbox upstream if their Beta model diverges from our needs. The `SandboxOrchestrator` interface (`src/sandbox-orchestrator.ts`) is the clean swap point — a new implementation can be added without touching `plugin.ts` business logic.
|
||||
|
||||
## Lessons learned (from openclaw-operator)
|
||||
|
||||
This plugin adopts patterns from `openclaw-rocks/openclaw-operator`:
|
||||
|
||||
- Tini PID 1 (issue #471 — zombie helper processes)
|
||||
- Read-only rootFS with explicit writable mounts (issue #456 — ~/.config not writable)
|
||||
- Strategic merge on reconcile (issue #446 — preserve third-party annotations)
|
||||
- Multi-storage-class testing (issue #448 — `local-path-provisioner` differences)
|
||||
- Image version compat matrix (issue #462 — runtime deps cannot resolve after upgrade)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd packages/plugins/sandbox-providers/kubernetes
|
||||
pnpm install --ignore-workspace
|
||||
pnpm test # unit tests only (fast)
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
To run the kind-cluster integration test (requires `kubectl --context kind-paperclip` and a pre-loaded alpine image; see `test/integration/end-to-end-run.test.ts`):
|
||||
|
||||
```bash
|
||||
RUN_K8S_INTEGRATION_TESTS=1 pnpm test test/integration/end-to-end-run.test.ts
|
||||
```
|
||||
@@ -0,0 +1,137 @@
|
||||
# Manual smoke test — `@paperclipai/plugin-kubernetes`
|
||||
|
||||
Manual sanity check that the plugin works end-to-end against a real
|
||||
paperclip-server instance and a real Kubernetes cluster (kind for local
|
||||
dev). Future work may automate this in CI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running kind cluster:
|
||||
```bash
|
||||
kind create cluster --name paperclip
|
||||
```
|
||||
- `kubectl --context kind-paperclip get nodes` returns a node in `Ready` state.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build the plugin
|
||||
|
||||
```bash
|
||||
cd packages/plugins/sandbox-providers/kubernetes
|
||||
pnpm install --ignore-workspace
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: `dist/` populated with compiled `.js` and `.d.ts` files. No errors.
|
||||
|
||||
### 2. Start paperclip-server in dev mode
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
cd /path/to/paperclip
|
||||
export PAPERCLIP_HOME=/tmp/paperclip-smoke
|
||||
export PAPERCLIP_INSTANCE_ID=smoke
|
||||
export PAPERCLIP_DEPLOYMENT_MODE=local_trusted
|
||||
pnpm --filter @paperclipai/server dev
|
||||
```
|
||||
|
||||
Wait for `Server listening on 127.0.0.1:3100`.
|
||||
|
||||
### 3. Install the plugin via the CLI
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install \
|
||||
--local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes \
|
||||
--api-base http://127.0.0.1:3100
|
||||
```
|
||||
|
||||
Expected: `✓ Installed paperclip.kubernetes-sandbox-provider v0.1.0 (ready)`.
|
||||
|
||||
### 4. Create a company and a kubernetes sandbox environment
|
||||
|
||||
```bash
|
||||
CO_ID=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{"name":"SmokeCo"}' \
|
||||
http://127.0.0.1:3100/api/companies | jq -r '.id')
|
||||
|
||||
KUBECONFIG_CONTENT=$(cat ~/.kube/config | jq -Rs .)
|
||||
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"k8s-sandbox\",
|
||||
\"driver\": \"sandbox\",
|
||||
\"config\": {
|
||||
\"provider\": \"kubernetes\",
|
||||
\"kubeconfig\": $KUBECONFIG_CONTENT,
|
||||
\"companySlug\": \"smoke\",
|
||||
\"adapterType\": \"claude_local\",
|
||||
\"imageAllowList\": [\"ghcr.io/paperclipai/agent-runtime-claude:v1\"]
|
||||
}
|
||||
}" \
|
||||
http://127.0.0.1:3100/api/companies/$CO_ID/environments | jq
|
||||
```
|
||||
|
||||
Expected: HTTP 201 with the new environment row.
|
||||
|
||||
### 5. Probe the environment
|
||||
|
||||
```bash
|
||||
ENV_ID=$(curl -s http://127.0.0.1:3100/api/companies/$CO_ID/environments | jq -r '.[0].id')
|
||||
curl -s -X POST -d '{}' -H "Content-Type: application/json" \
|
||||
http://127.0.0.1:3100/api/environments/$ENV_ID/probe | jq
|
||||
```
|
||||
|
||||
Expected: `{"ok": true, ...}` with a summary mentioning the tenant namespace
|
||||
(`paperclip-smoke`). On first probe the namespace may not yet exist —
|
||||
the plugin treats a 404 on `listNamespacedPod` as a successful reachability
|
||||
check.
|
||||
|
||||
### 6. Trigger an agent run
|
||||
|
||||
Use the UI or the API to dispatch a run against the `k8s-sandbox` environment.
|
||||
The plugin's `onEnvironmentAcquireLease` will:
|
||||
|
||||
1. `ensureTenant` — provision the `paperclip-smoke` namespace, SA, Role,
|
||||
RoleBinding, ResourceQuota, LimitRange, NetworkPolicies
|
||||
2. `buildSandboxCrManifest` — render the security-hardened Sandbox CR manifest
|
||||
3. `createNamespacedCustomObject` — submit to `agents.x-k8s.io/v1alpha1`
|
||||
4. `createPerRunSecret` — owned by the Sandbox CR for cascade-delete
|
||||
5. Fast-upload workspace/config/skill payloads by collapsing adapter-utils chunked uploads into a single stdin-backed exec per file
|
||||
|
||||
### 7. Verify the tenant resources
|
||||
|
||||
```bash
|
||||
kubectl --context kind-paperclip get namespace paperclip-smoke
|
||||
kubectl --context kind-paperclip get all,networkpolicy,resourcequota,limitrange,sa,role,rolebinding -n paperclip-smoke
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Namespace `paperclip-smoke` exists with PSS labels
|
||||
(`pod-security.kubernetes.io/enforce=restricted`)
|
||||
- ServiceAccount `paperclip-tenant-sa`
|
||||
- Role `paperclip-tenant-role`, RoleBinding `paperclip-tenant-rb`
|
||||
- ResourceQuota `paperclip-quota`, LimitRange `paperclip-limits`
|
||||
- NetworkPolicies `paperclip-deny-all` + `paperclip-egress-allow`
|
||||
- Sandbox `pc-{ulid}` and its managed Pod
|
||||
- Secret `pc-{ulid}-env` with `ownerReferences` pointing at the Sandbox CR
|
||||
- Run logs or plugin metadata include `fastUpload: "flush"` entries during workspace/config/skill upload
|
||||
|
||||
### 8. Tear down
|
||||
|
||||
```bash
|
||||
kubectl --context kind-paperclip delete namespace paperclip-smoke
|
||||
kill %1 # paperclip-server
|
||||
```
|
||||
|
||||
### 9. Document the result
|
||||
|
||||
In the PR description (or appended to this file as a dated section),
|
||||
record:
|
||||
|
||||
- Date + git SHA
|
||||
- `kubectl version` server version
|
||||
- Output of `kubectl get all -n paperclip-smoke` after step 6
|
||||
- Probe response from step 5
|
||||
- Time-to-acquire-lease (target: <30s on kind for a cold tenant)
|
||||
@@ -0,0 +1,22 @@
|
||||
# This plugin uses only stable Kubernetes APIs. No CRD installation is required.
|
||||
#
|
||||
# Minimum cluster version: Kubernetes 1.27+
|
||||
# - batch/v1 Job (GA since k8s 1.21)
|
||||
# - core/v1 Pod, Secret, Namespace, ServiceAccount, ResourceQuota, LimitRange (GA since k8s 1.0)
|
||||
# - rbac.authorization.k8s.io/v1 Role, RoleBinding (GA since k8s 1.8)
|
||||
# - networking.k8s.io/v1 NetworkPolicy (GA since k8s 1.7)
|
||||
# - Pod Security Standards namespace labels (GA in k8s 1.25)
|
||||
# - fsGroupChangePolicy: OnRootMismatch (GA in k8s 1.23)
|
||||
# - seccompProfile.type: RuntimeDefault (GA in k8s 1.19)
|
||||
#
|
||||
# Optional CNI prerequisites for FQDN-based egress (egressMode: cilium):
|
||||
# - Cilium >= 1.11 with hubble + DNS proxy enabled
|
||||
# - cilium.io/v2 CiliumNetworkPolicy (provided by Cilium installation)
|
||||
#
|
||||
# Optional runtime class for microVM isolation (runtimeClassName: kata-fc):
|
||||
# - kata-containers with Firecracker hypervisor
|
||||
# - nested-virt-capable nodes
|
||||
#
|
||||
# Future backends (not currently required):
|
||||
# - kubernetes-sigs/agent-sandbox (when it reaches v1beta1) as an alternative
|
||||
# backend for warm pools / templates / pause-resume.
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-kubernetes",
|
||||
"version": "0.1.0",
|
||||
"description": "Kubernetes sandbox provider plugin for Paperclip environments",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/sandbox-providers/kubernetes"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": ["dist", "manifests", "README.md"],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"sandbox",
|
||||
"kubernetes"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface AdapterDefaults {
|
||||
runtimeImage: string;
|
||||
envKeys: string[];
|
||||
allowFqdns: string[];
|
||||
probeCommand: string[];
|
||||
}
|
||||
|
||||
const REGISTRY: Record<string, AdapterDefaults> = {
|
||||
claude_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com"],
|
||||
probeCommand: ["claude", "--version"],
|
||||
},
|
||||
codex_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-codex:v1",
|
||||
envKeys: ["OPENAI_API_KEY"],
|
||||
allowFqdns: ["api.openai.com"],
|
||||
probeCommand: ["codex", "--version"],
|
||||
},
|
||||
gemini_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-gemini:v1",
|
||||
envKeys: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||
allowFqdns: ["generativelanguage.googleapis.com"],
|
||||
probeCommand: ["gemini", "--version"],
|
||||
},
|
||||
cursor_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-cursor:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com", "api.openai.com"],
|
||||
probeCommand: ["cursor-agent", "--version"],
|
||||
},
|
||||
opencode_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-opencode:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com", "api.openai.com", "openrouter.ai"],
|
||||
probeCommand: ["opencode", "--version"],
|
||||
},
|
||||
acpx_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-acpx:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com", "api.openai.com"],
|
||||
probeCommand: ["acpx", "--version"],
|
||||
},
|
||||
pi_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-pi:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com"],
|
||||
probeCommand: ["pi", "--version"],
|
||||
},
|
||||
};
|
||||
|
||||
export const KNOWN_ADAPTER_TYPES: ReadonlySet<string> = new Set(Object.keys(REGISTRY));
|
||||
|
||||
export function getAdapterDefaults(adapterType: string): AdapterDefaults {
|
||||
const defaults = REGISTRY[adapterType];
|
||||
if (!defaults) {
|
||||
throw new Error(`Unknown adapter type: ${adapterType}`);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface BuildCiliumNetworkPolicyInput {
|
||||
namespace: string;
|
||||
paperclipServerNamespace: string;
|
||||
egressAllowFqdns: string[];
|
||||
egressAllowCidrs: string[];
|
||||
}
|
||||
|
||||
// Design note: no ingress rules are defined here. Paperclip-server does NOT
|
||||
// push to agent pods — agents make outbound (egress) callbacks to
|
||||
// paperclip-server on port 3100. If server→agent push is ever needed, add a
|
||||
// targeted ingress rule scoped to the paperclip-server endpoint selector.
|
||||
export function buildCiliumNetworkPolicyManifest(input: BuildCiliumNetworkPolicyInput): Record<string, unknown> {
|
||||
const egress: Record<string, unknown>[] = [];
|
||||
|
||||
egress.push({
|
||||
toEndpoints: [
|
||||
{ matchLabels: { "k8s:io.kubernetes.pod.namespace": "kube-system", "k8s-app": "kube-dns" } },
|
||||
],
|
||||
toPorts: [
|
||||
{
|
||||
ports: [
|
||||
{ port: "53", protocol: "UDP" },
|
||||
{ port: "53", protocol: "TCP" },
|
||||
],
|
||||
rules: { dns: [{ matchPattern: "*" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (input.egressAllowFqdns.length > 0) {
|
||||
egress.push({
|
||||
toFQDNs: input.egressAllowFqdns.map((fqdn) => ({ matchName: fqdn })),
|
||||
toPorts: [{ ports: [{ port: "443", protocol: "TCP" }] }],
|
||||
});
|
||||
}
|
||||
|
||||
egress.push({
|
||||
toEndpoints: [
|
||||
{
|
||||
matchLabels: {
|
||||
"k8s:io.kubernetes.pod.namespace": input.paperclipServerNamespace,
|
||||
app: "paperclip-server",
|
||||
},
|
||||
},
|
||||
],
|
||||
toPorts: [{ ports: [{ port: "3100", protocol: "TCP" }] }],
|
||||
});
|
||||
|
||||
if (input.egressAllowCidrs.length > 0) {
|
||||
egress.push({
|
||||
toCIDRSet: input.egressAllowCidrs.map((cidr) => ({ cidr })),
|
||||
toPorts: [{ ports: [{ port: "443", protocol: "TCP" }] }],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion: "cilium.io/v2",
|
||||
kind: "CiliumNetworkPolicy",
|
||||
metadata: {
|
||||
name: "paperclip-egress-fqdn",
|
||||
namespace: input.namespace,
|
||||
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
|
||||
},
|
||||
spec: {
|
||||
endpointSelector: { matchLabels: { "paperclip.io/role": "agent" } },
|
||||
egress,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Glob matching for image references.
|
||||
* - `*` matches any sequence of characters EXCEPT `/` (so a wildcard doesn't span path segments)
|
||||
* - `?` matches exactly one character (excluding `/`)
|
||||
*/
|
||||
export function globMatch(pattern: string, value: string): boolean {
|
||||
const re = new RegExp(
|
||||
"^" +
|
||||
pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\?/g, "[^/]") +
|
||||
"$",
|
||||
);
|
||||
return re.test(value);
|
||||
}
|
||||
|
||||
export interface ResolveImageInput {
|
||||
imageOverride?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveImageDefaults {
|
||||
runtimeImage: string;
|
||||
}
|
||||
|
||||
export interface ResolveImageConfig {
|
||||
imageAllowList: string[];
|
||||
imageRegistry?: string;
|
||||
}
|
||||
|
||||
export function resolveImage(
|
||||
target: ResolveImageInput,
|
||||
defaults: ResolveImageDefaults,
|
||||
config: ResolveImageConfig,
|
||||
): string {
|
||||
if (target.imageOverride) {
|
||||
if (!config.imageAllowList.some((p) => globMatch(p, target.imageOverride!))) {
|
||||
throw new Error(`Image override "${target.imageOverride}" is not in allowlist`);
|
||||
}
|
||||
return target.imageOverride;
|
||||
}
|
||||
if (config.imageRegistry) {
|
||||
return rewriteRegistry(defaults.runtimeImage, config.imageRegistry);
|
||||
}
|
||||
return defaults.runtimeImage;
|
||||
}
|
||||
|
||||
function rewriteRegistry(image: string, registry: string): string {
|
||||
// image is like "ghcr.io/paperclipai/agent-runtime-claude:v1"
|
||||
// we want to replace the first two path segments (host + org) with `registry`
|
||||
const cleanRegistry = registry.replace(/\/+$/, "");
|
||||
const colonIdx = image.lastIndexOf(":");
|
||||
const tag = colonIdx >= 0 ? image.slice(colonIdx) : "";
|
||||
const path = colonIdx >= 0 ? image.slice(0, colonIdx) : image;
|
||||
const segments = path.split("/");
|
||||
// Strip the host+org (first two segments), keep the image name
|
||||
const imageName = segments.slice(2).join("/") || segments[segments.length - 1];
|
||||
return `${cleanRegistry}/${imageName}${tag}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./plugin.js";
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { KubeClients } from "./kube-client.js";
|
||||
import type { SandboxOrchestrator, SandboxStatus } from "./sandbox-orchestrator.js";
|
||||
|
||||
export class JobTimeoutError extends Error {
|
||||
constructor(namespace: string, name: string, timeoutMs: number) {
|
||||
super(`Job ${namespace}/${name} did not complete within ${timeoutMs}ms`);
|
||||
this.name = "JobTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createJob(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<{ uid: string }> {
|
||||
const result = await clients.batch.createNamespacedJob({ namespace, body: manifest as never });
|
||||
const uid = (result as { metadata?: { uid?: string } }).metadata?.uid;
|
||||
if (!uid) throw new Error("Job created without a UID");
|
||||
return { uid };
|
||||
}
|
||||
|
||||
export type JobStatus = SandboxStatus;
|
||||
|
||||
export async function getJobStatus(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<JobStatus> {
|
||||
const result = await clients.batch.readNamespacedJobStatus({ namespace, name });
|
||||
const body = (result as Record<string, unknown>) ?? {};
|
||||
const status = (body.status as Record<string, unknown>) ?? {};
|
||||
const active = (status.active as number) ?? 0;
|
||||
const succeeded = (status.succeeded as number) ?? 0;
|
||||
const failed = (status.failed as number) ?? 0;
|
||||
const conditions = (status.conditions as { type: string; status: string; reason?: string; message?: string }[]) ?? [];
|
||||
const completed = conditions.find((c) => c.type === "Complete" && c.status === "True");
|
||||
const failedCond = conditions.find((c) => c.type === "Failed" && c.status === "True");
|
||||
if (failedCond || failed > 0) {
|
||||
return { phase: "Failed", complete: false, active, succeeded, failed, reason: failedCond?.reason, message: failedCond?.message };
|
||||
}
|
||||
if (completed || succeeded > 0) {
|
||||
return { phase: "Succeeded", complete: true, active, succeeded, failed };
|
||||
}
|
||||
if (active > 0) {
|
||||
return { phase: "Running", complete: false, active, succeeded, failed };
|
||||
}
|
||||
return { phase: "Pending", complete: false, active, succeeded, failed };
|
||||
}
|
||||
|
||||
export async function findPodForJob(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
jobName: string,
|
||||
): Promise<string | null> {
|
||||
const result = await clients.core.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `job-name=${jobName}`,
|
||||
});
|
||||
const items = ((result as { items?: { metadata?: { name?: string }; status?: { phase?: string } }[] }).items) ?? [];
|
||||
const running = items.find((p) => p.status?.phase === "Running");
|
||||
return (running ?? items[0])?.metadata?.name ?? null;
|
||||
}
|
||||
|
||||
export async function streamPodLogs(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
// V1 limitation: the Pod log API returns the container's combined log stream.
|
||||
// Kubernetes does not preserve stdout/stderr channel separation after the
|
||||
// container runtime writes logs, so the Job backend reports combined logs on
|
||||
// stdout. The sandbox-cr backend uses exec and keeps streams separate.
|
||||
const result = await clients.core.readNamespacedPodLog({ namespace, name: podName });
|
||||
const text = readPodLogText(result);
|
||||
if (text.length > 0) await onChunk("stdout", text);
|
||||
}
|
||||
|
||||
function readPodLogText(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
const body = (result as { body?: unknown })?.body;
|
||||
return typeof body === "string" ? body : "";
|
||||
}
|
||||
|
||||
export async function deleteJob(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await clients.batch.deleteNamespacedJob({
|
||||
namespace,
|
||||
name,
|
||||
propagationPolicy: "Foreground",
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForJobCompletion(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
opts: { timeoutMs: number; pollMs?: number } = { timeoutMs: 120_000, pollMs: 2000 },
|
||||
): Promise<JobStatus> {
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
const pollMs = opts.pollMs ?? 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const status = await getJobStatus(clients, namespace, name);
|
||||
if (status.phase === "Succeeded" || status.phase === "Failed") return status;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
throw new JobTimeoutError(namespace, name, opts.timeoutMs);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Job-backed conformance to SandboxOrchestrator. Plugin.ts imports THIS value
|
||||
* (the swap point) — to use a different backend, swap this import for another
|
||||
* module exposing a SandboxOrchestrator-shaped default export.
|
||||
*/
|
||||
export const jobOrchestrator: SandboxOrchestrator = {
|
||||
claim: createJob,
|
||||
getStatus: getJobStatus,
|
||||
findPod: findPodForJob,
|
||||
streamLogs: streamPodLogs,
|
||||
release: deleteJob,
|
||||
waitForCompletion: waitForJobCompletion,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
KubeConfig,
|
||||
CoreV1Api,
|
||||
BatchV1Api,
|
||||
CustomObjectsApi,
|
||||
NetworkingV1Api,
|
||||
RbacAuthorizationV1Api,
|
||||
} from "@kubernetes/client-node";
|
||||
|
||||
export interface CreateKubeConfigInput {
|
||||
inCluster?: boolean;
|
||||
kubeconfig?: string;
|
||||
}
|
||||
|
||||
export function createKubeConfig(input: CreateKubeConfigInput): KubeConfig {
|
||||
const kc = new KubeConfig();
|
||||
if (input.inCluster) {
|
||||
kc.loadFromCluster();
|
||||
return kc;
|
||||
}
|
||||
if (input.kubeconfig && input.kubeconfig.trim().length > 0) {
|
||||
kc.loadFromString(input.kubeconfig);
|
||||
return kc;
|
||||
}
|
||||
throw new Error("createKubeConfig requires either inCluster=true or a kubeconfig string");
|
||||
}
|
||||
|
||||
export interface KubeClients {
|
||||
core: CoreV1Api;
|
||||
batch: BatchV1Api;
|
||||
custom: CustomObjectsApi;
|
||||
networking: NetworkingV1Api;
|
||||
rbac: RbacAuthorizationV1Api;
|
||||
}
|
||||
|
||||
export function makeKubeClients(kc: KubeConfig): KubeClients {
|
||||
return {
|
||||
core: kc.makeApiClient(CoreV1Api),
|
||||
batch: kc.makeApiClient(BatchV1Api),
|
||||
custom: kc.makeApiClient(CustomObjectsApi),
|
||||
networking: kc.makeApiClient(NetworkingV1Api),
|
||||
rbac: kc.makeApiClient(RbacAuthorizationV1Api),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.kubernetes-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0-alpha.1";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Kubernetes Sandbox (alpha)",
|
||||
description:
|
||||
"Built on kubernetes-sigs/agent-sandbox (v1alpha1). ALPHA — expect breaking changes as the upstream CRD evolves. Falls back to stable batch/v1 Job mode for clusters without agent-sandbox installed. First-party Paperclip sandbox-provider plugin for Kubernetes.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "kubernetes",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Kubernetes",
|
||||
description:
|
||||
"Dispatches agent runs in per-tenant Kubernetes namespaces. Default backend (sandbox-cr, alpha) uses kubernetes-sigs/agent-sandbox for multi-command exec; fallback backend (job) uses stable batch/v1 Job for clusters without agent-sandbox installed.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
inCluster: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true, the plugin uses the in-pod ServiceAccount credentials. Requires paperclip-server to be running inside the target cluster.",
|
||||
},
|
||||
kubeconfig: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
pattern: "\\S",
|
||||
description:
|
||||
"Inline kubeconfig YAML. Paste a kubeconfig or an existing Paperclip secret reference; pasted values are stored as company secrets.",
|
||||
},
|
||||
namespacePrefix: {
|
||||
type: "string",
|
||||
maxLength: 20,
|
||||
description: "Prefix for the per-company tenant namespace (default: paperclip-).",
|
||||
},
|
||||
paperclipServerNamespace: {
|
||||
type: "string",
|
||||
maxLength: 63,
|
||||
pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
description:
|
||||
"Namespace where paperclip-server pods run. Used by generated egress policies so agent pods can call back to the server (default: paperclip).",
|
||||
},
|
||||
companySlug: {
|
||||
type: "string",
|
||||
maxLength: 43,
|
||||
description: "Override the auto-derived company slug used in the tenant namespace name.",
|
||||
},
|
||||
imageRegistry: {
|
||||
type: "string",
|
||||
description: "Override the default registry for agent runtime images (default: ghcr.io/paperclipai).",
|
||||
},
|
||||
imageAllowList: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Glob patterns of allowed `target.imageOverride` values. Empty list = no override permitted.",
|
||||
},
|
||||
imagePullSecrets: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Names of pre-created Docker image pull secrets in the tenant namespace.",
|
||||
},
|
||||
egressAllowFqdns: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Additional FQDNs to allow egress to from agent pods. Adapter-default FQDNs (e.g. api.anthropic.com) are added automatically.",
|
||||
},
|
||||
egressAllowCidrs: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Additional CIDRs to allow HTTPS egress to from agent pods. CIDR egress is restricted to TCP port 443.",
|
||||
},
|
||||
egressMode: {
|
||||
type: "string",
|
||||
enum: ["standard", "cilium"],
|
||||
description:
|
||||
"Network policy mode. `standard` uses NetworkPolicy and allows public HTTPS when adapter FQDNs are configured; `cilium` enables exact FQDN egress filtering via CiliumNetworkPolicy.",
|
||||
},
|
||||
runtimeClassName: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional RuntimeClass for pod isolation (e.g. `kata-fc` for Firecracker-backed microVMs). Cluster must have the RuntimeClass installed.",
|
||||
},
|
||||
serviceAccountAnnotations: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
description:
|
||||
"Annotations applied to the per-tenant ServiceAccount (e.g. `eks.amazonaws.com/role-arn` for IRSA).",
|
||||
},
|
||||
jobTtlSecondsAfterFinished: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
description: "Seconds after a Job completes before it is garbage-collected (default: 900).",
|
||||
},
|
||||
podActivityDeadlineSec: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
description: "Hard ceiling on a single run's wall-clock time (default: 3600).",
|
||||
},
|
||||
adapterType: {
|
||||
type: "string",
|
||||
description:
|
||||
"The adapter type that Jobs in this environment will run (e.g. `claude_local`, `codex_local`). Defaults to `claude_local`. Each environment is bound to one adapter; create multiple environments for different adapters.",
|
||||
},
|
||||
backend: {
|
||||
type: "string",
|
||||
enum: ["sandbox-cr", "job"],
|
||||
description:
|
||||
"sandbox-cr (default, alpha — requires kubernetes-sigs/agent-sandbox installed) | job (stable fallback — batch/v1 Job, one-shot entrypoint, no multi-command exec)",
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{
|
||||
properties: { inCluster: { const: true } },
|
||||
required: ["inCluster"],
|
||||
},
|
||||
{ required: ["kubeconfig"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface BuildNetworkPolicyInput {
|
||||
namespace: string;
|
||||
paperclipServerNamespace: string;
|
||||
egressAllowFqdns: string[];
|
||||
egressAllowCidrs: string[];
|
||||
}
|
||||
|
||||
const PUBLIC_IPV4_EXCEPTIONS = [
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
];
|
||||
|
||||
// Design note: the deny-all baseline blocks all ingress to agent pods.
|
||||
// Paperclip-server does NOT push to agent pods — the agent shim makes
|
||||
// outbound calls to paperclip-server via the egress allow-list (port 3100).
|
||||
// This pull/callback model means no ingress rule is needed. If a future
|
||||
// feature requires server→agent push (e.g. forced shutdown, live exec),
|
||||
// add a targeted ingress rule here scoped to the paperclip-server pod
|
||||
// selector.
|
||||
//
|
||||
// Standard Kubernetes NetworkPolicy cannot express FQDN allow-lists. When
|
||||
// adapter defaults require FQDN egress, keep runs functional by allowing public
|
||||
// IPv4 HTTPS while excluding private/link-local ranges. Operators who need
|
||||
// exact FQDN enforcement should use egressMode="cilium".
|
||||
export function buildNetworkPolicyManifests(input: BuildNetworkPolicyInput): Record<string, unknown>[] {
|
||||
const fqdnsRequirePublicHttpsFallback = input.egressAllowFqdns.length > 0;
|
||||
const denyAll = {
|
||||
apiVersion: "networking.k8s.io/v1",
|
||||
kind: "NetworkPolicy",
|
||||
metadata: {
|
||||
name: "paperclip-deny-all",
|
||||
namespace: input.namespace,
|
||||
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
|
||||
},
|
||||
spec: {
|
||||
podSelector: {},
|
||||
policyTypes: ["Ingress", "Egress"],
|
||||
},
|
||||
};
|
||||
|
||||
const egressAllow: Record<string, unknown> = {
|
||||
apiVersion: "networking.k8s.io/v1",
|
||||
kind: "NetworkPolicy",
|
||||
metadata: {
|
||||
name: "paperclip-egress-allow",
|
||||
namespace: input.namespace,
|
||||
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
|
||||
},
|
||||
spec: {
|
||||
podSelector: { matchLabels: { "paperclip.io/role": "agent" } },
|
||||
policyTypes: ["Egress"],
|
||||
egress: [
|
||||
{
|
||||
to: [
|
||||
{
|
||||
namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": "kube-system" } },
|
||||
podSelector: { matchLabels: { "k8s-app": "kube-dns" } },
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{ protocol: "UDP", port: 53 },
|
||||
{ protocol: "TCP", port: 53 },
|
||||
],
|
||||
},
|
||||
{
|
||||
to: [
|
||||
{
|
||||
namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": input.paperclipServerNamespace } },
|
||||
podSelector: { matchLabels: { app: "paperclip-server" } },
|
||||
},
|
||||
],
|
||||
ports: [{ protocol: "TCP", port: 3100 }],
|
||||
},
|
||||
...(fqdnsRequirePublicHttpsFallback
|
||||
? [
|
||||
{
|
||||
to: [
|
||||
{
|
||||
ipBlock: {
|
||||
cidr: "0.0.0.0/0",
|
||||
except: PUBLIC_IPV4_EXCEPTIONS,
|
||||
},
|
||||
},
|
||||
],
|
||||
ports: [{ protocol: "TCP", port: 443 }],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...input.egressAllowCidrs.map((cidr) => ({
|
||||
to: [{ ipBlock: { cidr } }],
|
||||
ports: [{ protocol: "TCP", port: 443 }],
|
||||
})),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return [denyAll, egressAllow];
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
kubernetesProviderConfigSchema,
|
||||
type KubernetesProviderConfig,
|
||||
type KubernetesLeaseMetadata,
|
||||
} from "./types.js";
|
||||
import { createKubeConfig, makeKubeClients } from "./kube-client.js";
|
||||
import { getAdapterDefaults } from "./adapter-defaults.js";
|
||||
import { resolveImage } from "./image-allowlist.js";
|
||||
import { buildJobManifest } from "./pod-spec-builder.js";
|
||||
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
|
||||
import { ensureTenant } from "./tenant-orchestrator.js";
|
||||
import { createPerRunSecret } from "./secret-manager.js";
|
||||
import { FastUploadInterceptor } from "./upload-interceptor.js";
|
||||
import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
|
||||
import {
|
||||
sandboxCrOrchestrator,
|
||||
SandboxCrTimeoutError,
|
||||
} from "./sandbox-cr-orchestrator.js";
|
||||
import { execInPod } from "./pod-exec.js";
|
||||
import { shellQuoteArg } from "./shell-utils.js";
|
||||
import {
|
||||
deriveCompanySlug,
|
||||
deriveNamespaceName,
|
||||
newRunUlidDns,
|
||||
paperclipLabels,
|
||||
} from "./utils.js";
|
||||
|
||||
// Name of the ServiceAccount created inside each tenant namespace by ensureTenant.
|
||||
const TENANT_SERVICE_ACCOUNT = "paperclip-tenant-sa";
|
||||
|
||||
// Resource quota defaults applied to every tenant namespace (M4b; tunable via
|
||||
// config in a future milestone).
|
||||
const DEFAULT_RESOURCE_QUOTA = {
|
||||
pods: "20",
|
||||
requestsCpu: "10",
|
||||
requestsMemory: "20Gi",
|
||||
limitsCpu: "20",
|
||||
limitsMemory: "40Gi",
|
||||
};
|
||||
|
||||
const uploadInterceptorsByLease = new Map<string, FastUploadInterceptor>();
|
||||
|
||||
function getOrCreateUploadInterceptor(leaseId: string): FastUploadInterceptor {
|
||||
let interceptor = uploadInterceptorsByLease.get(leaseId);
|
||||
if (!interceptor) {
|
||||
interceptor = new FastUploadInterceptor();
|
||||
uploadInterceptorsByLease.set(leaseId, interceptor);
|
||||
}
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
function extractShellScript(
|
||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||
): string | null {
|
||||
const command = typeof params.command === "string" ? params.command.trim() : "";
|
||||
const args = Array.isArray(params.args) ? params.args : [];
|
||||
const isShell = command === "sh" || command === "bash" || command.endsWith("/sh") || command.endsWith("/bash");
|
||||
if (isShell && args[0] === "-c" && typeof args[1] === "string") {
|
||||
return args[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveTenantNamespace(config: KubernetesProviderConfig, companyId: string): string {
|
||||
// TODO: future versions could thread companyName through AcquireLeaseParams
|
||||
// to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
|
||||
const slug = config.companySlug ?? deriveCompanySlug(companyId);
|
||||
return deriveNamespaceName(config.namespacePrefix, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads adapter env keys (e.g. ANTHROPIC_API_KEY) from the current process
|
||||
* environment. The plugin worker runs inside paperclip-server's pod, which has
|
||||
* these vars injected at deploy time.
|
||||
*
|
||||
* M4b approach: env vars sourced from process.env at acquire time.
|
||||
* TODO: future milestones may thread per-run secrets differently (e.g. via
|
||||
* a secret store reference on the environment config).
|
||||
*/
|
||||
export function extractAdapterEnvFromProcess(
|
||||
envKeys: string[],
|
||||
warn: (message: string) => void = console.warn,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
for (const k of envKeys) {
|
||||
const v = process.env[k];
|
||||
if (v !== undefined) {
|
||||
out[k] = v;
|
||||
} else {
|
||||
missing.push(k);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
warn(
|
||||
`[plugin-kubernetes] adapter environment variable(s) missing from plugin worker process: ${missing.join(", ")}. Agent pods may fail provider authentication unless these keys are optional for the selected adapter.`,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildSandboxExecCommand(
|
||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||
): string[] {
|
||||
const command = typeof params.command === "string" ? params.command.trim() : "";
|
||||
const args = Array.isArray(params.args) ? params.args : [];
|
||||
|
||||
if (command.length > 0 && args.length > 0) {
|
||||
return [command, ...args];
|
||||
}
|
||||
if (command.length > 0) {
|
||||
return ["/bin/sh", "-lc", command];
|
||||
}
|
||||
if (args.length > 0) {
|
||||
return ["/bin/sh", "-lc", args.map(shellQuoteArg).join(" ")];
|
||||
}
|
||||
return ["/bin/sh", "-l"];
|
||||
}
|
||||
|
||||
export function deriveUploadTargetDir(targetPath: string): string {
|
||||
const slashIndex = targetPath.lastIndexOf("/");
|
||||
return slashIndex >= 0 ? targetPath.slice(0, slashIndex) || "/" : ".";
|
||||
}
|
||||
|
||||
export function buildSandboxExecShellCommand(
|
||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||
): string {
|
||||
if (typeof params.command === "string" && params.command.trim().length > 0) {
|
||||
return params.command;
|
||||
}
|
||||
|
||||
return params.args?.map(shellQuoteArg).join(" ") ?? "";
|
||||
}
|
||||
|
||||
function generateBootstrapToken(): string {
|
||||
// TODO: paperclip-server's actual callback auth scheme is separate and is
|
||||
// out of M4b scope. This per-run random token is stored in the per-run
|
||||
// Secret and consumed by paperclip-agent-shim for initial registration.
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("Kubernetes sandbox provider plugin ready");
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Kubernetes sandbox provider plugin healthy" };
|
||||
},
|
||||
|
||||
async onEnvironmentValidateConfig(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
): Promise<PluginEnvironmentValidationResult> {
|
||||
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: parsed.error.issues.map((i) => i.message),
|
||||
};
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const cfg = parsed.data;
|
||||
const adapterDefaults = getAdapterDefaults(cfg.adapterType);
|
||||
const totalFqdns = [...adapterDefaults.allowFqdns, ...cfg.egressAllowFqdns];
|
||||
if (cfg.egressMode === "standard" && totalFqdns.length > 0) {
|
||||
warnings.push(
|
||||
`egressMode=standard cannot enforce FQDN-based egress rules for ${totalFqdns.join(", ")}. Agent pods will get public IPv4 HTTPS egress with private/link-local ranges excluded. Switch egressMode to "cilium" for exact FQDN enforcement.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, normalizedConfig: cfg as Record<string, unknown>, warnings: warnings.length > 0 ? warnings : undefined };
|
||||
},
|
||||
|
||||
async onEnvironmentProbe(
|
||||
params: PluginEnvironmentProbeParams,
|
||||
): Promise<PluginEnvironmentProbeResult> {
|
||||
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: "Invalid Kubernetes provider configuration.",
|
||||
metadata: {
|
||||
errors: parsed.error.issues.map((i) => i.message),
|
||||
},
|
||||
};
|
||||
}
|
||||
const config = parsed.data;
|
||||
const namespace = deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
try {
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
// Reachability check: list pods in the tenant namespace. If the namespace
|
||||
// doesn't exist yet this will throw a 404 which we treat as "reachable
|
||||
// but namespace not provisioned" — still a successful probe.
|
||||
try {
|
||||
await clients.core.listNamespacedPod({ namespace });
|
||||
} catch (err) {
|
||||
const code = (err as { code?: number; statusCode?: number }).code
|
||||
?? (err as { code?: number; statusCode?: number }).statusCode;
|
||||
if (code !== 404) throw err;
|
||||
// 404 means namespace doesn't exist yet — cluster is reachable.
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Kubernetes cluster reachable. Tenant namespace: ${namespace}.`,
|
||||
metadata: { namespace, provider: "kubernetes" },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: "Kubernetes cluster probe failed.",
|
||||
metadata: {
|
||||
namespace,
|
||||
provider: "kubernetes",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentAcquireLease(
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace = deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
// Emit a runtime warning if FQDNs are configured but egressMode=standard
|
||||
// cannot enforce them. Mirrors the validateConfig warning so operators see
|
||||
// it in paperclip-server logs even if they missed the validation step.
|
||||
const adapterDefaultsForWarn = getAdapterDefaults(config.adapterType);
|
||||
const totalFqdnsForWarn = [...adapterDefaultsForWarn.allowFqdns, ...config.egressAllowFqdns];
|
||||
if (config.egressMode === "standard" && totalFqdnsForWarn.length > 0) {
|
||||
// The SDK does not currently thread ctx.logger into environment hooks.
|
||||
// Keep this explicit so operators still see the standard-mode egress
|
||||
// trade-off in raw worker logs.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[plugin-kubernetes] egressMode=standard cannot enforce FQDN-based egress rules for ${totalFqdnsForWarn.join(", ")}. Agent pods will get public IPv4 HTTPS egress with private/link-local ranges excluded. Switch egressMode to "cilium" for exact FQDN enforcement.`,
|
||||
);
|
||||
}
|
||||
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
// Ensure the tenant namespace and all its RBAC / network policy resources
|
||||
// exist before we try to create the Job.
|
||||
const adapterDefaults = getAdapterDefaults(config.adapterType);
|
||||
|
||||
await ensureTenant(clients, {
|
||||
namespace,
|
||||
companyId: params.companyId,
|
||||
paperclipServerNamespace: config.paperclipServerNamespace,
|
||||
serviceAccountAnnotations: config.serviceAccountAnnotations,
|
||||
egressMode: config.egressMode,
|
||||
egressAllowFqdns: [...adapterDefaults.allowFqdns, ...config.egressAllowFqdns],
|
||||
egressAllowCidrs: config.egressAllowCidrs,
|
||||
resourceQuota: DEFAULT_RESOURCE_QUOTA,
|
||||
});
|
||||
|
||||
const jobName = `pc-${newRunUlidDns()}`;
|
||||
const secretName = `${jobName}-env`;
|
||||
|
||||
// TODO: use params.runId as stand-in for agentId in labels; future
|
||||
// versions will have a dedicated agentId on AcquireLeaseParams.
|
||||
const labels = paperclipLabels({
|
||||
runId: params.runId,
|
||||
agentId: params.runId,
|
||||
companyId: params.companyId,
|
||||
adapterType: config.adapterType,
|
||||
});
|
||||
|
||||
const image = resolveImage(
|
||||
{ imageOverride: null },
|
||||
adapterDefaults,
|
||||
{ imageAllowList: config.imageAllowList, imageRegistry: config.imageRegistry },
|
||||
);
|
||||
|
||||
// Pick the orchestrator and build the appropriate manifest based on backend.
|
||||
const isSandboxCrBackend = config.backend === "sandbox-cr";
|
||||
const orchestrator = isSandboxCrBackend ? sandboxCrOrchestrator : jobOrchestrator;
|
||||
|
||||
const manifest = isSandboxCrBackend
|
||||
? buildSandboxCrManifest({
|
||||
namespace,
|
||||
sandboxName: jobName,
|
||||
adapterType: config.adapterType,
|
||||
image,
|
||||
envSecretName: secretName,
|
||||
serviceAccountName: TENANT_SERVICE_ACCOUNT,
|
||||
labels,
|
||||
resources: config.defaultResources ?? {},
|
||||
runtimeClassName: config.runtimeClassName,
|
||||
imagePullSecrets: config.imagePullSecrets,
|
||||
})
|
||||
: buildJobManifest({
|
||||
namespace,
|
||||
jobName,
|
||||
adapterType: config.adapterType,
|
||||
image,
|
||||
envSecretName: secretName,
|
||||
serviceAccountName: TENANT_SERVICE_ACCOUNT,
|
||||
labels,
|
||||
resources: config.defaultResources ?? {},
|
||||
runtimeClassName: config.runtimeClassName,
|
||||
activeDeadlineSec: config.podActivityDeadlineSec,
|
||||
ttlSecondsAfterFinished: config.jobTtlSecondsAfterFinished,
|
||||
imagePullSecrets: config.imagePullSecrets,
|
||||
});
|
||||
|
||||
const { uid: ownerUid } = await orchestrator.claim(clients, namespace, manifest);
|
||||
|
||||
// M4b: adapter env vars are sourced from the plugin worker's own process
|
||||
// environment (paperclip-server pod has them injected at deploy time).
|
||||
const adapterEnv = extractAdapterEnvFromProcess(adapterDefaults.envKeys);
|
||||
const bootstrapToken = generateBootstrapToken();
|
||||
|
||||
// Secret ownerRef: for job backend, the Job owns the Secret (cascade delete).
|
||||
// For sandbox-cr backend, the Sandbox CR owns the Secret.
|
||||
// NOTE: For sandbox-cr, if the Secret outlives the Sandbox due to a cluster
|
||||
// quirk, the release() call will still clean it up via namespace GC or
|
||||
// explicit delete in a future milestone.
|
||||
try {
|
||||
await createPerRunSecret(clients, {
|
||||
namespace,
|
||||
secretName,
|
||||
runId: params.runId,
|
||||
ownerKind: isSandboxCrBackend ? "Sandbox" : "Job",
|
||||
ownerApiVersion: isSandboxCrBackend ? "agents.x-k8s.io/v1alpha1" : "batch/v1",
|
||||
ownerName: jobName,
|
||||
ownerUid,
|
||||
bootstrapToken,
|
||||
adapterEnv,
|
||||
});
|
||||
|
||||
const podName = await orchestrator.findPod(clients, namespace, jobName);
|
||||
|
||||
const leaseMetadata: KubernetesLeaseMetadata = {
|
||||
namespace,
|
||||
jobName,
|
||||
podName,
|
||||
secretName,
|
||||
phase: "Pending",
|
||||
backend: config.backend,
|
||||
};
|
||||
|
||||
return {
|
||||
providerLeaseId: jobName,
|
||||
metadata: leaseMetadata as unknown as Record<string, unknown>,
|
||||
};
|
||||
} catch (err) {
|
||||
try {
|
||||
await orchestrator.release(clients, namespace, jobName);
|
||||
} catch (cleanupErr) {
|
||||
throw new Error(
|
||||
`Kubernetes lease setup failed and cleanup also failed: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentRealizeWorkspace(
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
||||
// The agent pod already has /workspace mounted as an emptyDir at pod
|
||||
// scheduling time (see pod-spec-builder). Nothing to provision here —
|
||||
// we just hand back the cwd. Honor a caller-supplied remotePath if set.
|
||||
const cwd =
|
||||
params.workspace.remotePath && params.workspace.remotePath.trim().length > 0
|
||||
? params.workspace.remotePath.trim()
|
||||
: "/workspace";
|
||||
return {
|
||||
cwd,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
remoteCwd: cwd,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace =
|
||||
typeof params.leaseMetadata?.namespace === "string"
|
||||
? params.leaseMetadata.namespace
|
||||
: deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
const leaseBackend =
|
||||
typeof params.leaseMetadata?.backend === "string"
|
||||
? (params.leaseMetadata.backend as "sandbox-cr" | "job")
|
||||
: config.backend;
|
||||
const releaseOrchestrator =
|
||||
leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
|
||||
|
||||
uploadInterceptorsByLease.delete(params.providerLeaseId);
|
||||
|
||||
try {
|
||||
await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
|
||||
} catch (err) {
|
||||
// If the resource is already gone (404), that's fine.
|
||||
const code = (err as { code?: number; statusCode?: number }).code
|
||||
?? (err as { code?: number; statusCode?: number }).statusCode;
|
||||
if (code !== 404) throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentExecute(
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
): Promise<PluginEnvironmentExecuteResult> {
|
||||
const { lease, timeoutMs } = params;
|
||||
|
||||
if (!lease.providerLeaseId) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "No provider lease ID available for execution.",
|
||||
};
|
||||
}
|
||||
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace =
|
||||
typeof lease.metadata?.namespace === "string"
|
||||
? lease.metadata.namespace
|
||||
: deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
// Determine which backend this lease was created with.
|
||||
const leaseBackend =
|
||||
typeof lease.metadata?.backend === "string"
|
||||
? (lease.metadata.backend as "sandbox-cr" | "job")
|
||||
: config.backend;
|
||||
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
const effectiveTimeoutMs =
|
||||
typeof timeoutMs === "number" && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: config.podActivityDeadlineSec * 1000;
|
||||
|
||||
if (leaseBackend === "sandbox-cr") {
|
||||
// ── Sandbox-CR backend ──────────────────────────────────────────────────
|
||||
// 1. Ensure the Sandbox pod is Ready (wait if needed).
|
||||
// 2. Exec the command into the running pod.
|
||||
// 3. Return exec result directly (no log scraping needed).
|
||||
const executeStartedAt = Date.now();
|
||||
|
||||
let podName =
|
||||
typeof lease.metadata?.podName === "string" && lease.metadata.podName
|
||||
? lease.metadata.podName
|
||||
: null;
|
||||
|
||||
// Wait for pod Ready if we don't have a pod name yet (or as a health check).
|
||||
try {
|
||||
await sandboxCrOrchestrator.waitForCompletion(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxCrTimeoutError) {
|
||||
return {
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
stdout: "",
|
||||
stderr: `Sandbox pod did not become Ready within ${effectiveTimeoutMs}ms`,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Resolve pod name (may now be populated in Sandbox status).
|
||||
if (!podName) {
|
||||
podName = await sandboxCrOrchestrator.findPod(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
);
|
||||
}
|
||||
|
||||
if (!podName) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Sandbox pod is Ready but podName could not be resolved.",
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const remainingTimeoutMs = Math.max(1, effectiveTimeoutMs - (Date.now() - executeStartedAt));
|
||||
|
||||
const shellScript = extractShellScript(params);
|
||||
if (shellScript) {
|
||||
const decision = getOrCreateUploadInterceptor(lease.providerLeaseId).decide(shellScript);
|
||||
if (decision.action === "ack") {
|
||||
return {
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
fastUpload: "ack",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (decision.action === "flush") {
|
||||
const base64Body = decision.flush.payload.toString("base64");
|
||||
const dir = deriveUploadTargetDir(decision.flush.targetPath);
|
||||
const script =
|
||||
`mkdir -p ${shellQuoteArg(dir)} && ` +
|
||||
`base64 -d > ${shellQuoteArg(decision.flush.targetPath)}`;
|
||||
const flushResult = await execInPod(
|
||||
kc,
|
||||
namespace,
|
||||
podName,
|
||||
"agent",
|
||||
["/bin/sh", "-c", script],
|
||||
base64Body,
|
||||
remainingTimeoutMs,
|
||||
);
|
||||
return {
|
||||
exitCode: flushResult.exitCode,
|
||||
timedOut: flushResult.timedOut,
|
||||
stdout: flushResult.stdout,
|
||||
stderr: flushResult.stderr,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
fastUpload: "flush",
|
||||
uploadedBytes: decision.flush.payload.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (decision.action === "error") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: decision.message,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
fastUpload: "error",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const execCommand = buildSandboxExecCommand(params);
|
||||
const execResult = await execInPod(
|
||||
kc,
|
||||
namespace,
|
||||
podName,
|
||||
"agent",
|
||||
execCommand,
|
||||
typeof params.stdin === "string" ? params.stdin : undefined,
|
||||
remainingTimeoutMs,
|
||||
);
|
||||
|
||||
return {
|
||||
exitCode: execResult.exitCode,
|
||||
timedOut: execResult.timedOut,
|
||||
stdout: execResult.stdout,
|
||||
stderr: execResult.stderr,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// ── Job backend (legacy / stable fallback) ──────────────────────────────
|
||||
// The container entrypoint is baked into the Job spec (Tini + paperclip-agent-shim).
|
||||
// We do NOT re-exec command/args — instead we wait for the Job to finish
|
||||
// and collect its logs.
|
||||
//
|
||||
// params.command / params.args / params.stdin are intentionally ignored.
|
||||
|
||||
let status;
|
||||
let timedOut = false;
|
||||
try {
|
||||
status = await jobOrchestrator.waitForCompletion(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof JobTimeoutError) {
|
||||
timedOut = true;
|
||||
status = null;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect logs from the pod.
|
||||
const podName =
|
||||
typeof lease.metadata?.podName === "string"
|
||||
? lease.metadata.podName
|
||||
: await jobOrchestrator.findPod(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
);
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
if (podName) {
|
||||
await jobOrchestrator.streamLogs(
|
||||
clients,
|
||||
namespace,
|
||||
podName,
|
||||
async (stream, text) => {
|
||||
if (stream === "stdout") stdoutChunks.push(text);
|
||||
else stderrChunks.push(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: timedOut ? null : status?.phase === "Succeeded" ? 0 : 1,
|
||||
timedOut,
|
||||
stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join(""),
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "job",
|
||||
namespace,
|
||||
jobName: lease.providerLeaseId,
|
||||
podName: podName ?? null,
|
||||
phase: status?.phase ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Exec a command inside a running pod container using the Kubernetes exec API.
|
||||
*
|
||||
* Uses @kubernetes/client-node's Exec class, which opens a WebSocket to the
|
||||
* kube-apiserver and streams stdout/stderr. The statusCallback receives a V1Status
|
||||
* with status="Success" or status="Failure" + details.causes[{reason:"ExitCode"}].
|
||||
*
|
||||
* NOTE: tty=false so stdout and stderr arrive on separate channels. If tty=true
|
||||
* were used, they would be merged onto stdout and the exit code would not be
|
||||
* reliable from the status callback on older cluster versions.
|
||||
*/
|
||||
|
||||
import { Exec } from "@kubernetes/client-node";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { KubeConfig } from "@kubernetes/client-node";
|
||||
import { shellQuoteArg } from "./shell-utils.js";
|
||||
|
||||
type WebSocketLike = {
|
||||
close(): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
};
|
||||
|
||||
export interface ExecInPodResult {
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function execInPod(
|
||||
kc: KubeConfig,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
containerName: string,
|
||||
command: string[],
|
||||
stdin?: string | Buffer,
|
||||
timeoutMs?: number,
|
||||
): Promise<ExecInPodResult> {
|
||||
const exec = new Exec(kc);
|
||||
const stdoutStream = new PassThrough();
|
||||
const stderrStream = new PassThrough();
|
||||
|
||||
const stdinPayload: Buffer | null =
|
||||
Buffer.isBuffer(stdin) ? stdin
|
||||
: typeof stdin === "string" && stdin.length > 0 ? Buffer.from(stdin, "utf-8")
|
||||
: null;
|
||||
const stdinStream: PassThrough | null = stdinPayload ? new PassThrough() : null;
|
||||
const effectiveCommand = stdinPayload
|
||||
? ["/bin/sh", "-c", `head -c ${stdinPayload.length} | ${command.map(shellQuoteArg).join(" ")}`]
|
||||
: command;
|
||||
|
||||
let stdoutData = "";
|
||||
let stderrData = "";
|
||||
|
||||
stdoutStream.on("data", (chunk: Buffer) => {
|
||||
stdoutData += chunk.toString("utf-8");
|
||||
});
|
||||
stderrStream.on("data", (chunk: Buffer) => {
|
||||
stderrData += chunk.toString("utf-8");
|
||||
});
|
||||
stdoutStream.on("error", () => {});
|
||||
stderrStream.on("error", () => {});
|
||||
|
||||
return await new Promise<ExecInPodResult>(
|
||||
(resolve, reject) => {
|
||||
let ws: WebSocketLike | null = null;
|
||||
let settled = false;
|
||||
let pendingResult: Omit<ExecInPodResult, "stdout" | "stderr"> | null = null;
|
||||
let stdoutEnded = false;
|
||||
let stderrEnded = false;
|
||||
const timeout =
|
||||
typeof timeoutMs === "number" && timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
finishWithTransportFailure(`Kubernetes exec timed out after ${timeoutMs}ms`, true);
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
const finish = (result: ExecInPodResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
try {
|
||||
ws?.close();
|
||||
} catch {
|
||||
// Ignore best-effort close failures.
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const finishWithTransportFailure = (message: string, timedOut = false) => {
|
||||
const separator = stderrData.length > 0 && !stderrData.endsWith("\n") ? "\n" : "";
|
||||
finish({
|
||||
exitCode: 1,
|
||||
timedOut,
|
||||
stdout: stdoutData,
|
||||
stderr: `${stderrData}${separator}${message}`,
|
||||
});
|
||||
};
|
||||
const tryFinish = () => {
|
||||
if (settled || !pendingResult || !stdoutEnded || !stderrEnded) return;
|
||||
finish({
|
||||
...pendingResult,
|
||||
stdout: stdoutData,
|
||||
stderr: stderrData,
|
||||
});
|
||||
};
|
||||
const endOutputStreams = () => {
|
||||
if (!stdoutStream.writableEnded) stdoutStream.end();
|
||||
if (!stderrStream.writableEnded) stderrStream.end();
|
||||
};
|
||||
|
||||
stdoutStream.on("end", () => {
|
||||
stdoutEnded = true;
|
||||
tryFinish();
|
||||
});
|
||||
stderrStream.on("end", () => {
|
||||
stderrEnded = true;
|
||||
tryFinish();
|
||||
});
|
||||
|
||||
const websocketPromise = exec
|
||||
.exec(
|
||||
namespace,
|
||||
podName,
|
||||
containerName,
|
||||
effectiveCommand,
|
||||
stdoutStream,
|
||||
stderrStream,
|
||||
stdinStream,
|
||||
false, // tty=false: keep stdout/stderr on separate channels
|
||||
(status) => {
|
||||
// status.status is "Success" | "Failure"
|
||||
if (status.status === "Success") {
|
||||
pendingResult = { exitCode: 0, timedOut: false };
|
||||
endOutputStreams();
|
||||
tryFinish();
|
||||
return;
|
||||
}
|
||||
// On failure, the exit code surfaces via
|
||||
// status.details?.causes[].{reason:"ExitCode", message:"<N>"}
|
||||
const causes = status.details?.causes ?? [];
|
||||
const exitCodeCause = causes.find(
|
||||
(c: { reason?: string; message?: string }) =>
|
||||
c.reason === "ExitCode",
|
||||
);
|
||||
const exitCode = exitCodeCause?.message
|
||||
? Number(exitCodeCause.message)
|
||||
: 1;
|
||||
pendingResult = { exitCode, timedOut: false };
|
||||
endOutputStreams();
|
||||
tryFinish();
|
||||
},
|
||||
);
|
||||
|
||||
websocketPromise
|
||||
.then((webSocket) => {
|
||||
ws = webSocket as WebSocketLike;
|
||||
if (settled) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore best-effort close failures.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stdinStream && stdinPayload) {
|
||||
stdinStream.end(stdinPayload);
|
||||
}
|
||||
ws.on("close", (code: number, reason: Buffer) => {
|
||||
if (settled || pendingResult) return;
|
||||
const reasonText = reason.length > 0 ? `: ${reason.toString("utf-8")}` : "";
|
||||
finishWithTransportFailure(`Kubernetes exec websocket closed before status frame (${code})${reasonText}`);
|
||||
});
|
||||
ws.on("error", (err: Error) => {
|
||||
if (settled || pendingResult) return;
|
||||
finishWithTransportFailure(`Kubernetes exec websocket failed before status frame: ${err.message}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (settled) return;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface BuildJobManifestInput {
|
||||
namespace: string;
|
||||
jobName: string;
|
||||
adapterType: string;
|
||||
image: string;
|
||||
envSecretName: string;
|
||||
serviceAccountName: string;
|
||||
labels: Record<string, string>;
|
||||
resources: {
|
||||
requests?: { cpu?: string; memory?: string };
|
||||
limits?: { cpu?: string; memory?: string };
|
||||
};
|
||||
runtimeClassName?: string;
|
||||
activeDeadlineSec: number;
|
||||
ttlSecondsAfterFinished: number;
|
||||
imagePullSecrets?: string[];
|
||||
}
|
||||
|
||||
export function buildJobManifest(input: BuildJobManifestInput): Record<string, unknown> {
|
||||
const podLabels = {
|
||||
...input.labels,
|
||||
"paperclip.io/role": "agent",
|
||||
};
|
||||
return {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
metadata: {
|
||||
name: input.jobName,
|
||||
namespace: input.namespace,
|
||||
labels: { ...input.labels },
|
||||
},
|
||||
spec: {
|
||||
backoffLimit: 0,
|
||||
ttlSecondsAfterFinished: input.ttlSecondsAfterFinished,
|
||||
activeDeadlineSeconds: input.activeDeadlineSec,
|
||||
template: {
|
||||
metadata: { labels: podLabels },
|
||||
spec: {
|
||||
serviceAccountName: input.serviceAccountName,
|
||||
// Agent containers call back to paperclip-server via HTTPS egress;
|
||||
// they never call the Kubernetes API, so mounting an SA token is
|
||||
// unnecessary attack surface.
|
||||
automountServiceAccountToken: false,
|
||||
restartPolicy: "Never",
|
||||
...(input.runtimeClassName ? { runtimeClassName: input.runtimeClassName } : {}),
|
||||
...(input.imagePullSecrets && input.imagePullSecrets.length > 0
|
||||
? { imagePullSecrets: input.imagePullSecrets.map((name) => ({ name })) }
|
||||
: {}),
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
fsGroup: 1000,
|
||||
fsGroupChangePolicy: "OnRootMismatch",
|
||||
seccompProfile: { type: "RuntimeDefault" },
|
||||
},
|
||||
containers: [
|
||||
{
|
||||
name: "agent",
|
||||
image: input.image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/usr/bin/tini", "--", "/usr/local/bin/paperclip-agent-shim"],
|
||||
envFrom: [{ secretRef: { name: input.envSecretName } }],
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
readOnlyRootFilesystem: true,
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: { drop: ["ALL"] },
|
||||
},
|
||||
resources: {
|
||||
requests: input.resources.requests ?? { cpu: "250m", memory: "512Mi" },
|
||||
limits: input.resources.limits ?? { cpu: "2", memory: "4Gi" },
|
||||
},
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "home", mountPath: "/home/paperclip" },
|
||||
{ name: "cache", mountPath: "/home/paperclip/.cache" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
],
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
|
||||
{ name: "home", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "cache", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "tmp", emptyDir: { sizeLimit: "2Gi" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Builds a kubernetes-sigs/agent-sandbox Sandbox CR manifest.
|
||||
*
|
||||
* The Sandbox CR creates a long-lived pod (sleep infinity entrypoint) into
|
||||
* which paperclip-server can exec arbitrary commands. This solves the
|
||||
* architectural mismatch with the batch/v1 Job backend, which only supports
|
||||
* a single one-shot entrypoint — not the multi-command adapter-install pattern
|
||||
* used by paperclip-server.
|
||||
*
|
||||
* Security baseline is identical to buildJobManifest (pod-spec-builder.ts):
|
||||
* non-root, drop ALL caps, read-only rootFS, Tini PID 1, seccomp
|
||||
* RuntimeDefault, fsGroupChangePolicy OnRootMismatch, automountSAToken=false.
|
||||
*
|
||||
* NOTE: paperclip-server runs OUTSIDE the cluster, so we cannot set ownerReferences
|
||||
* on the Sandbox CR (the owner would need to be an in-cluster resource). The
|
||||
* release path is explicit delete via sandboxCrOrchestrator.release().
|
||||
*/
|
||||
|
||||
export interface BuildSandboxCrManifestInput {
|
||||
namespace: string;
|
||||
sandboxName: string;
|
||||
adapterType: string;
|
||||
image: string;
|
||||
envSecretName: string;
|
||||
serviceAccountName: string;
|
||||
labels: Record<string, string>;
|
||||
resources: {
|
||||
requests?: { cpu?: string; memory?: string };
|
||||
limits?: { cpu?: string; memory?: string };
|
||||
};
|
||||
runtimeClassName?: string;
|
||||
imagePullSecrets?: string[];
|
||||
}
|
||||
|
||||
export function buildSandboxCrManifest(
|
||||
input: BuildSandboxCrManifestInput,
|
||||
): Record<string, unknown> {
|
||||
const podLabels: Record<string, string> = {
|
||||
...input.labels,
|
||||
"paperclip.io/role": "agent",
|
||||
};
|
||||
return {
|
||||
apiVersion: "agents.x-k8s.io/v1alpha1",
|
||||
kind: "Sandbox",
|
||||
metadata: {
|
||||
name: input.sandboxName,
|
||||
namespace: input.namespace,
|
||||
labels: { ...input.labels },
|
||||
// No ownerReferences: paperclip-server is out-of-cluster. Release is
|
||||
// explicit delete.
|
||||
},
|
||||
spec: {
|
||||
podTemplate: {
|
||||
metadata: {
|
||||
labels: podLabels,
|
||||
},
|
||||
spec: {
|
||||
serviceAccountName: input.serviceAccountName,
|
||||
// Agent containers call back to paperclip-server via HTTPS egress;
|
||||
// they never call the Kubernetes API, so mounting an SA token is
|
||||
// unnecessary attack surface.
|
||||
automountServiceAccountToken: false,
|
||||
// Sandbox controller requires restartPolicy: Always so the pod
|
||||
// stays running between exec calls.
|
||||
restartPolicy: "Always",
|
||||
...(input.runtimeClassName
|
||||
? { runtimeClassName: input.runtimeClassName }
|
||||
: {}),
|
||||
...(input.imagePullSecrets && input.imagePullSecrets.length > 0
|
||||
? {
|
||||
imagePullSecrets: input.imagePullSecrets.map((name) => ({
|
||||
name,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
fsGroup: 1000,
|
||||
fsGroupChangePolicy: "OnRootMismatch",
|
||||
seccompProfile: { type: "RuntimeDefault" },
|
||||
},
|
||||
containers: [
|
||||
{
|
||||
name: "agent",
|
||||
image: input.image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
// sleep infinity keeps the pod running; paperclip-server execs
|
||||
// commands into it via Kubernetes exec API. Tini as PID 1 for
|
||||
// proper signal forwarding and zombie reaping.
|
||||
command: [
|
||||
"/usr/bin/tini",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"sleep infinity",
|
||||
],
|
||||
envFrom: [{ secretRef: { name: input.envSecretName } }],
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
readOnlyRootFilesystem: true,
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: { drop: ["ALL"] },
|
||||
},
|
||||
resources: {
|
||||
requests: input.resources.requests ?? {
|
||||
cpu: "250m",
|
||||
memory: "512Mi",
|
||||
},
|
||||
limits: input.resources.limits ?? {
|
||||
cpu: "2",
|
||||
memory: "4Gi",
|
||||
},
|
||||
},
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "home", mountPath: "/home/paperclip" },
|
||||
{ name: "cache", mountPath: "/home/paperclip/.cache" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
],
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
|
||||
{ name: "home", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "cache", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "tmp", emptyDir: { sizeLimit: "2Gi" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* SandboxOrchestrator implementation backed by the kubernetes-sigs/agent-sandbox
|
||||
* Sandbox CRD (agents.x-k8s.io/v1alpha1).
|
||||
*
|
||||
* The Sandbox CR creates a long-lived pod that paperclip-server can exec into
|
||||
* for multi-command adapter-install workflows — the key architectural win over
|
||||
* the batch/v1 Job backend.
|
||||
*
|
||||
* Key semantic differences from jobOrchestrator:
|
||||
* - claim() creates a Sandbox CR via CustomObjectsApi instead of a batch Job
|
||||
* - getStatus() maps Sandbox phase (Pending|Ready|Terminating|Failed) to SandboxStatus
|
||||
* - findPod() reads status.podName from the Sandbox CR (falls back to label query)
|
||||
* - waitForCompletion() means "wait until pod is Ready to exec" NOT "wait until
|
||||
* workload finishes". The Sandbox pod runs sleep infinity; execution completion
|
||||
* is tracked by the individual execInPod() calls.
|
||||
* - release() deletes the Sandbox CR with Foreground propagation (controller
|
||||
* tears down the underlying pod).
|
||||
*
|
||||
* NOTE: streamLogs() is provided for interface conformance but is limited —
|
||||
* the sleep-infinity pod has no meaningful stdout. Callers in execute mode
|
||||
* should use execInPod() and capture its stdout/stderr directly.
|
||||
*/
|
||||
|
||||
import type { KubeClients } from "./kube-client.js";
|
||||
import type { SandboxOrchestrator, SandboxStatus } from "./sandbox-orchestrator.js";
|
||||
|
||||
const SANDBOX_GROUP = "agents.x-k8s.io";
|
||||
const SANDBOX_VERSION = "v1alpha1";
|
||||
const SANDBOX_PLURAL = "sandboxes";
|
||||
|
||||
export class SandboxCrTimeoutError extends Error {
|
||||
constructor(namespace: string, name: string, timeoutMs: number) {
|
||||
super(
|
||||
`Sandbox ${namespace}/${name} did not reach Ready phase within ${timeoutMs}ms`,
|
||||
);
|
||||
this.name = "SandboxCrTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Sandbox CR status.phase value to our SandboxStatus shape.
|
||||
* Sandbox phases: Pending | Ready | Terminating | Failed
|
||||
*/
|
||||
function mapSandboxPhase(
|
||||
cr: Record<string, unknown>,
|
||||
): SandboxStatus {
|
||||
const status = (cr.status as Record<string, unknown>) ?? {};
|
||||
const phase = (status.phase as string) ?? "Pending";
|
||||
|
||||
switch (phase) {
|
||||
case "Ready":
|
||||
return {
|
||||
phase: "Running", // SandboxStatus.phase uses Job semantics; "Running" = active pod
|
||||
complete: false,
|
||||
active: 1,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
};
|
||||
case "Terminating":
|
||||
return {
|
||||
phase: "Running",
|
||||
complete: false,
|
||||
active: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
reason: "Terminating",
|
||||
};
|
||||
case "Failed": {
|
||||
const conditions = (status.conditions as { type?: string; reason?: string; message?: string }[]) ?? [];
|
||||
const failedCond = conditions.find((c) => c.type === "Failed");
|
||||
return {
|
||||
phase: "Failed",
|
||||
complete: false,
|
||||
active: 0,
|
||||
succeeded: 0,
|
||||
failed: 1,
|
||||
reason: failedCond?.reason,
|
||||
message: failedCond?.message,
|
||||
};
|
||||
}
|
||||
default:
|
||||
// "Pending" or unknown
|
||||
return {
|
||||
phase: "Pending",
|
||||
complete: false,
|
||||
active: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSandboxCr(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<{ uid: string }> {
|
||||
const result = await clients.custom.createNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
body: manifest,
|
||||
});
|
||||
const uid = (result as { metadata?: { uid?: string } }).metadata?.uid;
|
||||
if (!uid) throw new Error("Sandbox CR created without a UID");
|
||||
return { uid };
|
||||
}
|
||||
|
||||
export async function getSandboxCrStatus(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<SandboxStatus> {
|
||||
const result = await clients.custom.getNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
});
|
||||
return mapSandboxPhase(result as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pod name backing a Sandbox CR.
|
||||
* Primary: read status.podName from the CR (set by the controller once ready).
|
||||
* Fallback: list pods in the namespace filtered by the paperclip.io/managed-by
|
||||
* label and the sandbox name label set on the pod template.
|
||||
*/
|
||||
export async function findPodForSandbox(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
// Primary: read status.podName from the Sandbox CR
|
||||
const cr = await clients.custom.getNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const status = (cr.status as Record<string, unknown>) ?? {};
|
||||
const podName = status.podName as string | undefined;
|
||||
if (podName && podName.trim().length > 0) {
|
||||
return podName;
|
||||
}
|
||||
|
||||
// Fallback: list pods with sandbox-name label (sandbox controller typically
|
||||
// labels pods with the sandbox name)
|
||||
const result = await clients.core.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `paperclip.io/managed-by=paperclip-k8s-plugin`,
|
||||
});
|
||||
const items =
|
||||
(
|
||||
(
|
||||
result as {
|
||||
items?: {
|
||||
metadata?: { name?: string; labels?: Record<string, string> };
|
||||
status?: { phase?: string };
|
||||
}[];
|
||||
}
|
||||
).items
|
||||
) ?? [];
|
||||
|
||||
// Filter to pods that belong to this sandbox by name prefix or label
|
||||
const matching = items.filter((p) => {
|
||||
const podMeta = p.metadata ?? {};
|
||||
const labels = podMeta.labels ?? {};
|
||||
// The sandbox controller may label pods differently; try matching by name prefix
|
||||
return (
|
||||
podMeta.name?.startsWith(name) ||
|
||||
labels["agents.x-k8s.io/sandbox-name"] === name
|
||||
);
|
||||
});
|
||||
|
||||
const running = matching.find((p) => p.status?.phase === "Running");
|
||||
return (running ?? matching[0])?.metadata?.name ?? null;
|
||||
}
|
||||
|
||||
export async function streamSandboxLogs(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
// V1 limitation: the Pod log API returns the container's combined log stream. The
|
||||
// sleep-infinity pod will have minimal output; this is provided for interface
|
||||
// conformance. For actual command output, use execInPod() directly.
|
||||
const result = await clients.core.readNamespacedPodLog({
|
||||
namespace,
|
||||
name: podName,
|
||||
});
|
||||
const text =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: typeof (result as { body?: unknown })?.body === "string"
|
||||
? (result as { body: string }).body
|
||||
: "";
|
||||
if (text.length > 0) await onChunk("stdout", text);
|
||||
}
|
||||
|
||||
export async function deleteSandboxCr(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await clients.custom.deleteNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
propagationPolicy: "Foreground",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the Sandbox CR's pod reaches Ready phase (i.e., the pod is up and
|
||||
* exec-able). This is NOT waiting for a workload to finish — the Sandbox pod
|
||||
* runs sleep infinity indefinitely. Execution completion is tracked by the
|
||||
* individual execInPod() calls.
|
||||
*
|
||||
* Throws SandboxCrTimeoutError if Ready is not reached within timeoutMs.
|
||||
* Throws if the Sandbox transitions to Failed.
|
||||
*/
|
||||
export async function waitForSandboxReady(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
opts: { timeoutMs: number; pollMs?: number } = {
|
||||
timeoutMs: 120_000,
|
||||
pollMs: 2000,
|
||||
},
|
||||
): Promise<SandboxStatus> {
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
const pollMs = opts.pollMs ?? 2000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const cr = await clients.custom.getNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const status = (cr.status as Record<string, unknown>) ?? {};
|
||||
const phase = (status.phase as string) ?? "Pending";
|
||||
|
||||
if (phase === "Ready") {
|
||||
return mapSandboxPhase(cr);
|
||||
}
|
||||
if (phase === "Failed") {
|
||||
const mapped = mapSandboxPhase(cr);
|
||||
throw new Error(
|
||||
`Sandbox ${namespace}/${name} failed: ${mapped.reason ?? "unknown reason"} — ${mapped.message ?? ""}`,
|
||||
);
|
||||
}
|
||||
if (phase === "Terminating") {
|
||||
throw new Error(`Sandbox ${namespace}/${name} is terminating before it became ready`);
|
||||
}
|
||||
// Pending or unknown — keep polling
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
throw new SandboxCrTimeoutError(namespace, name, opts.timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox CR-backed conformance to SandboxOrchestrator.
|
||||
*
|
||||
* waitForCompletion semantics change: for this backend, "completion" means
|
||||
* "pod is up and Ready to exec into" — NOT "workload finished". The actual
|
||||
* command execution and its completion is handled by execInPod().
|
||||
*/
|
||||
export const sandboxCrOrchestrator: SandboxOrchestrator = {
|
||||
claim: createSandboxCr,
|
||||
getStatus: getSandboxCrStatus,
|
||||
findPod: findPodForSandbox,
|
||||
streamLogs: streamSandboxLogs,
|
||||
release: deleteSandboxCr,
|
||||
waitForCompletion: waitForSandboxReady,
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { KubeClients } from "./kube-client.js";
|
||||
|
||||
export interface SandboxStatus {
|
||||
phase: "Pending" | "Running" | "Succeeded" | "Failed";
|
||||
complete: boolean;
|
||||
active: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract interface over a sandbox runtime backend. The current implementation
|
||||
* is Job-backed (job-orchestrator.ts). Future backends slot in by exporting an
|
||||
* object conforming to this shape — e.g. a Kata-FC warm-pool backend that
|
||||
* additionally implements the optional pause/resume slots, or a CRD-backed
|
||||
* backend on kubernetes-sigs/agent-sandbox once it reaches Beta.
|
||||
*/
|
||||
export interface SandboxOrchestrator {
|
||||
/** Provision the sandbox. Returns the runtime's stable UID. */
|
||||
claim(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<{ uid: string }>;
|
||||
|
||||
/** Read current lifecycle phase. */
|
||||
getStatus(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<SandboxStatus>;
|
||||
|
||||
/** Locate the pod backing this sandbox (or null if none exists yet). */
|
||||
findPod(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<string | null>;
|
||||
|
||||
/** Read logs from the sandbox's pod. V1: post-completion read. */
|
||||
streamLogs(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
|
||||
): Promise<void>;
|
||||
|
||||
/** Tear down the sandbox. Implementations MUST cascade-delete child resources. */
|
||||
release(clients: KubeClients, namespace: string, name: string): Promise<void>;
|
||||
|
||||
/** Block until phase is Succeeded or Failed, or throw on timeout. */
|
||||
waitForCompletion(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
opts: { timeoutMs: number; pollMs?: number },
|
||||
): Promise<SandboxStatus>;
|
||||
|
||||
// Optional warm-pool / Kata-FC extension slots. Job-backed implementation
|
||||
// does not provide these; runtimes that do (e.g. Kata-FC microVM pause)
|
||||
// implement them and acquire the warm-pool capability.
|
||||
// TODO: requires custom in-cluster controller for k8s — kubelet does not
|
||||
// expose pause/resume at the pod level. Add when warm-pool design lands.
|
||||
pause?(clients: KubeClients, namespace: string, name: string): Promise<void>;
|
||||
resume?(clients: KubeClients, namespace: string, name: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { KubeClients } from "./kube-client.js";
|
||||
|
||||
export interface CreatePerRunSecretInput {
|
||||
namespace: string;
|
||||
secretName: string;
|
||||
runId: string;
|
||||
ownerKind: string;
|
||||
ownerApiVersion: string;
|
||||
ownerName: string;
|
||||
ownerUid: string;
|
||||
bootstrapToken: string;
|
||||
adapterEnv: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function createPerRunSecret(clients: KubeClients, input: CreatePerRunSecretInput): Promise<void> {
|
||||
if (!input.ownerUid) {
|
||||
throw new Error("createPerRunSecret requires a non-empty ownerUid");
|
||||
}
|
||||
if ("BOOTSTRAP_TOKEN" in input.adapterEnv) {
|
||||
throw new Error("adapterEnv must not contain BOOTSTRAP_TOKEN (reserved key)");
|
||||
}
|
||||
await clients.core.createNamespacedSecret({
|
||||
namespace: input.namespace,
|
||||
body: {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
type: "Opaque",
|
||||
metadata: {
|
||||
name: input.secretName,
|
||||
namespace: input.namespace,
|
||||
labels: {
|
||||
"paperclip.io/run-id": input.runId,
|
||||
"paperclip.io/managed-by": "paperclip-k8s-plugin",
|
||||
},
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: input.ownerApiVersion,
|
||||
kind: input.ownerKind,
|
||||
name: input.ownerName,
|
||||
uid: input.ownerUid,
|
||||
controller: true,
|
||||
blockOwnerDeletion: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
stringData: {
|
||||
BOOTSTRAP_TOKEN: input.bootstrapToken,
|
||||
...input.adapterEnv,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shellQuoteArg(arg: string): string {
|
||||
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { KubeClients } from "./kube-client.js";
|
||||
import { buildNetworkPolicyManifests } from "./network-policy.js";
|
||||
import { buildCiliumNetworkPolicyManifest } from "./cilium-network-policy.js";
|
||||
|
||||
export interface EnsureTenantInput {
|
||||
namespace: string;
|
||||
companyId: string;
|
||||
paperclipServerNamespace: string;
|
||||
serviceAccountAnnotations: Record<string, string>;
|
||||
egressMode: "standard" | "cilium";
|
||||
egressAllowFqdns: string[];
|
||||
egressAllowCidrs: string[];
|
||||
resourceQuota: {
|
||||
pods: string;
|
||||
requestsCpu: string;
|
||||
requestsMemory: string;
|
||||
limitsCpu: string;
|
||||
limitsMemory: string;
|
||||
};
|
||||
}
|
||||
|
||||
const SERVICE_ACCOUNT_NAME = "paperclip-tenant-sa";
|
||||
const ROLE_NAME = "paperclip-tenant-role";
|
||||
const ROLE_BINDING_NAME = "paperclip-tenant-rb";
|
||||
const RESOURCE_QUOTA_NAME = "paperclip-quota";
|
||||
const LIMIT_RANGE_NAME = "paperclip-limits";
|
||||
|
||||
/**
|
||||
* Tenant provisioning reconciles the resources this plugin owns. Existing
|
||||
* resources are replaced with the desired manifest so quota, RBAC, service
|
||||
* account annotations, and egress policy changes take effect on the next run.
|
||||
*/
|
||||
export async function ensureTenant(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
await ensureNamespace(clients, input);
|
||||
await ensureServiceAccount(clients, input);
|
||||
await ensureRole(clients, input);
|
||||
await ensureRoleBinding(clients, input);
|
||||
await ensureResourceQuota(clients, input);
|
||||
await ensureLimitRange(clients, input);
|
||||
await ensureNetworkPolicies(clients, input);
|
||||
}
|
||||
|
||||
async function ensureNamespace(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
const manifest = buildNamespaceManifest(input);
|
||||
try {
|
||||
const existing = await clients.core.readNamespace({ name: input.namespace });
|
||||
await clients.core.replaceNamespace({
|
||||
name: input.namespace,
|
||||
body: withResourceVersion(buildNamespaceManifest(input, existing), existing) as never,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.core.createNamespace({ body: manifest });
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.core.readNamespace({ name: input.namespace });
|
||||
await clients.core.replaceNamespace({
|
||||
name: input.namespace,
|
||||
body: withResourceVersion(buildNamespaceManifest(input, existing), existing) as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildNamespaceManifest(input: EnsureTenantInput, existing?: unknown): Record<string, unknown> {
|
||||
const existingLabels = (existing as { metadata?: { labels?: Record<string, string> } })?.metadata?.labels ?? {};
|
||||
return {
|
||||
apiVersion: "v1",
|
||||
kind: "Namespace",
|
||||
metadata: {
|
||||
name: input.namespace,
|
||||
labels: {
|
||||
...existingLabels,
|
||||
"paperclip.io/company-id": input.companyId,
|
||||
"paperclip.io/managed-by": "paperclip-k8s-plugin",
|
||||
"pod-security.kubernetes.io/enforce": "restricted",
|
||||
"pod-security.kubernetes.io/audit": "restricted",
|
||||
"pod-security.kubernetes.io/warn": "restricted",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureServiceAccount(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
const manifest = {
|
||||
apiVersion: "v1",
|
||||
kind: "ServiceAccount",
|
||||
metadata: {
|
||||
name: SERVICE_ACCOUNT_NAME,
|
||||
namespace: input.namespace,
|
||||
annotations: input.serviceAccountAnnotations,
|
||||
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
|
||||
},
|
||||
};
|
||||
try {
|
||||
const existing = await clients.core.readNamespacedServiceAccount({ name: SERVICE_ACCOUNT_NAME, namespace: input.namespace });
|
||||
await clients.core.replaceNamespacedServiceAccount({
|
||||
name: SERVICE_ACCOUNT_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.core.createNamespacedServiceAccount({ namespace: input.namespace, body: manifest });
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.core.readNamespacedServiceAccount({ name: SERVICE_ACCOUNT_NAME, namespace: input.namespace });
|
||||
await clients.core.replaceNamespacedServiceAccount({
|
||||
name: SERVICE_ACCOUNT_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRole(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
const manifest = {
|
||||
apiVersion: "rbac.authorization.k8s.io/v1",
|
||||
kind: "Role",
|
||||
metadata: { name: ROLE_NAME, namespace: input.namespace },
|
||||
rules: [
|
||||
{ apiGroups: [""], resources: ["pods/log"], verbs: ["get"] },
|
||||
],
|
||||
};
|
||||
try {
|
||||
const existing = await clients.rbac.readNamespacedRole({ name: ROLE_NAME, namespace: input.namespace });
|
||||
await clients.rbac.replaceNamespacedRole({
|
||||
name: ROLE_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.rbac.createNamespacedRole({ namespace: input.namespace, body: manifest });
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.rbac.readNamespacedRole({ name: ROLE_NAME, namespace: input.namespace });
|
||||
await clients.rbac.replaceNamespacedRole({
|
||||
name: ROLE_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRoleBinding(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
const manifest = {
|
||||
apiVersion: "rbac.authorization.k8s.io/v1",
|
||||
kind: "RoleBinding",
|
||||
metadata: { name: ROLE_BINDING_NAME, namespace: input.namespace },
|
||||
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: ROLE_NAME },
|
||||
subjects: [{ kind: "ServiceAccount", name: SERVICE_ACCOUNT_NAME, namespace: input.namespace }],
|
||||
};
|
||||
try {
|
||||
const existing = await clients.rbac.readNamespacedRoleBinding({ name: ROLE_BINDING_NAME, namespace: input.namespace });
|
||||
await clients.rbac.replaceNamespacedRoleBinding({
|
||||
name: ROLE_BINDING_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.rbac.createNamespacedRoleBinding({ namespace: input.namespace, body: manifest });
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.rbac.readNamespacedRoleBinding({ name: ROLE_BINDING_NAME, namespace: input.namespace });
|
||||
await clients.rbac.replaceNamespacedRoleBinding({
|
||||
name: ROLE_BINDING_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureResourceQuota(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
const manifest = {
|
||||
apiVersion: "v1",
|
||||
kind: "ResourceQuota",
|
||||
metadata: { name: RESOURCE_QUOTA_NAME, namespace: input.namespace },
|
||||
spec: {
|
||||
hard: {
|
||||
pods: input.resourceQuota.pods,
|
||||
"requests.cpu": input.resourceQuota.requestsCpu,
|
||||
"requests.memory": input.resourceQuota.requestsMemory,
|
||||
"limits.cpu": input.resourceQuota.limitsCpu,
|
||||
"limits.memory": input.resourceQuota.limitsMemory,
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
const existing = await clients.core.readNamespacedResourceQuota({ name: RESOURCE_QUOTA_NAME, namespace: input.namespace });
|
||||
await clients.core.replaceNamespacedResourceQuota({
|
||||
name: RESOURCE_QUOTA_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.core.createNamespacedResourceQuota({ namespace: input.namespace, body: manifest });
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.core.readNamespacedResourceQuota({ name: RESOURCE_QUOTA_NAME, namespace: input.namespace });
|
||||
await clients.core.replaceNamespacedResourceQuota({
|
||||
name: RESOURCE_QUOTA_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLimitRange(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
const manifest = {
|
||||
apiVersion: "v1",
|
||||
kind: "LimitRange",
|
||||
metadata: { name: LIMIT_RANGE_NAME, namespace: input.namespace },
|
||||
spec: {
|
||||
limits: [
|
||||
{
|
||||
type: "Container",
|
||||
max: { cpu: "4", memory: "8Gi" },
|
||||
min: { cpu: "100m", memory: "128Mi" },
|
||||
// The k8s client-node type names this `_default` but the actual
|
||||
// Kubernetes API field is `default`. We produce a JSON-shape
|
||||
// manifest so the cast is safe.
|
||||
default: { cpu: "1", memory: "2Gi" },
|
||||
defaultRequest: { cpu: "250m", memory: "512Mi" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
try {
|
||||
const existing = await clients.core.readNamespacedLimitRange({ name: LIMIT_RANGE_NAME, namespace: input.namespace });
|
||||
await clients.core.replaceNamespacedLimitRange({
|
||||
name: LIMIT_RANGE_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.core.createNamespacedLimitRange({
|
||||
namespace: input.namespace,
|
||||
body: manifest as never,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.core.readNamespacedLimitRange({ name: LIMIT_RANGE_NAME, namespace: input.namespace });
|
||||
await clients.core.replaceNamespacedLimitRange({
|
||||
name: LIMIT_RANGE_NAME,
|
||||
namespace: input.namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNetworkPolicies(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
|
||||
const [denyAll, egressStd] = buildNetworkPolicyManifests({
|
||||
namespace: input.namespace,
|
||||
paperclipServerNamespace: input.paperclipServerNamespace,
|
||||
egressAllowFqdns: input.egressAllowFqdns,
|
||||
egressAllowCidrs: input.egressAllowCidrs,
|
||||
});
|
||||
|
||||
await ensureNetworkPolicy(clients, input.namespace, denyAll);
|
||||
|
||||
if (input.egressMode === "cilium") {
|
||||
const cnp = buildCiliumNetworkPolicyManifest({
|
||||
namespace: input.namespace,
|
||||
paperclipServerNamespace: input.paperclipServerNamespace,
|
||||
egressAllowFqdns: input.egressAllowFqdns,
|
||||
egressAllowCidrs: input.egressAllowCidrs,
|
||||
});
|
||||
await ensureCiliumNetworkPolicy(clients, input.namespace, cnp);
|
||||
await deleteNetworkPolicyIfExists(clients, input.namespace, "paperclip-egress-allow");
|
||||
} else {
|
||||
await ensureNetworkPolicy(clients, input.namespace, egressStd);
|
||||
await deleteCiliumNetworkPolicyIfExists(clients, input.namespace, "paperclip-egress-fqdn");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNetworkPolicy(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const name = (manifest.metadata as { name: string }).name;
|
||||
try {
|
||||
const existing = await clients.networking.readNamespacedNetworkPolicy({ name, namespace });
|
||||
await clients.networking.replaceNamespacedNetworkPolicy({
|
||||
name,
|
||||
namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.networking.createNamespacedNetworkPolicy({ namespace, body: manifest as never });
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.networking.readNamespacedNetworkPolicy({ name, namespace });
|
||||
await clients.networking.replaceNamespacedNetworkPolicy({
|
||||
name,
|
||||
namespace,
|
||||
body: withResourceVersion(manifest, existing) as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCiliumNetworkPolicy(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const name = (manifest.metadata as { name: string }).name;
|
||||
try {
|
||||
const existing = await clients.custom.getNamespacedCustomObject({
|
||||
group: "cilium.io",
|
||||
version: "v2",
|
||||
namespace,
|
||||
plural: "ciliumnetworkpolicies",
|
||||
name,
|
||||
});
|
||||
await clients.custom.replaceNamespacedCustomObject({
|
||||
group: "cilium.io",
|
||||
version: "v2",
|
||||
namespace,
|
||||
plural: "ciliumnetworkpolicies",
|
||||
name,
|
||||
body: withResourceVersion(manifest, existing),
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await clients.custom.createNamespacedCustomObject({
|
||||
group: "cilium.io",
|
||||
version: "v2",
|
||||
namespace,
|
||||
plural: "ciliumnetworkpolicies",
|
||||
body: manifest,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAlreadyExists(err)) throw err;
|
||||
const existing = await clients.custom.getNamespacedCustomObject({
|
||||
group: "cilium.io",
|
||||
version: "v2",
|
||||
namespace,
|
||||
plural: "ciliumnetworkpolicies",
|
||||
name,
|
||||
});
|
||||
await clients.custom.replaceNamespacedCustomObject({
|
||||
group: "cilium.io",
|
||||
version: "v2",
|
||||
namespace,
|
||||
plural: "ciliumnetworkpolicies",
|
||||
name,
|
||||
body: withResourceVersion(manifest, existing),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNetworkPolicyIfExists(clients: KubeClients, namespace: string, name: string): Promise<void> {
|
||||
try {
|
||||
await clients.networking.deleteNamespacedNetworkPolicy({ name, namespace });
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCiliumNetworkPolicyIfExists(clients: KubeClients, namespace: string, name: string): Promise<void> {
|
||||
try {
|
||||
await clients.custom.deleteNamespacedCustomObject({
|
||||
group: "cilium.io",
|
||||
version: "v2",
|
||||
namespace,
|
||||
plural: "ciliumnetworkpolicies",
|
||||
name,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isNotFound(err)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function withResourceVersion<T extends Record<string, unknown>>(manifest: T, existing: unknown): T {
|
||||
const resourceVersion = (existing as { metadata?: { resourceVersion?: string } })?.metadata?.resourceVersion;
|
||||
if (!resourceVersion) return manifest;
|
||||
return {
|
||||
...manifest,
|
||||
metadata: {
|
||||
...(manifest.metadata as Record<string, unknown>),
|
||||
resourceVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) return false;
|
||||
const e = err as { code?: number; statusCode?: number };
|
||||
return e.code === 404 || e.statusCode === 404;
|
||||
}
|
||||
|
||||
function isAlreadyExists(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) return false;
|
||||
const e = err as { code?: number; statusCode?: number };
|
||||
return e.code === 409 || e.statusCode === 409;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { z } from "zod";
|
||||
import { KNOWN_ADAPTER_TYPES } from "./adapter-defaults.js";
|
||||
|
||||
function isIpv4Cidr(value: string): boolean {
|
||||
const [address, prefix, extra] = value.split("/");
|
||||
if (!address || !prefix || extra !== undefined || !/^\d+$/.test(prefix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefixNumber = Number(prefix);
|
||||
if (prefixNumber < 0 || prefixNumber > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const octets = address.split(".");
|
||||
return octets.length === 4 && octets.every((octet) => {
|
||||
if (!/^\d+$/.test(octet)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = Number(octet);
|
||||
return value >= 0 && value <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
export const kubernetesProviderConfigSchema = z
|
||||
.object({
|
||||
inCluster: z.boolean().default(false),
|
||||
kubeconfig: z.string().optional(),
|
||||
|
||||
namespacePrefix: z.string().regex(/^[a-z0-9-]{1,20}$/).default("paperclip-"),
|
||||
paperclipServerNamespace: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(63)
|
||||
.regex(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/)
|
||||
.default("paperclip"),
|
||||
companySlug: z.string().regex(/^[a-z0-9-]{1,43}$/).optional(),
|
||||
|
||||
imageRegistry: z.string().url().optional(),
|
||||
imageAllowList: z.array(z.string()).default([]),
|
||||
imagePullSecrets: z.array(z.string()).default([]),
|
||||
|
||||
egressAllowFqdns: z.array(z.string()).default([]),
|
||||
egressAllowCidrs: z.array(z.string().refine(isIpv4Cidr, "Invalid CIDR")).default([]),
|
||||
egressMode: z.enum(["cilium", "standard"]).default("standard"),
|
||||
|
||||
defaultResources: z
|
||||
.object({
|
||||
requests: z.object({ cpu: z.string(), memory: z.string() }).partial().optional(),
|
||||
limits: z.object({ cpu: z.string(), memory: z.string() }).partial().optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
runtimeClassName: z.string().optional(),
|
||||
serviceAccountAnnotations: z.record(z.string()).default({}),
|
||||
|
||||
jobTtlSecondsAfterFinished: z.number().int().nonnegative().default(900),
|
||||
podActivityDeadlineSec: z.number().int().positive().default(3600),
|
||||
|
||||
/**
|
||||
* The adapter type that Jobs in this environment will run.
|
||||
* Each Kubernetes environment is bound to one adapter; create multiple
|
||||
* environments for different adapters.
|
||||
* Defaults to `"claude_local"`.
|
||||
*/
|
||||
adapterType: z
|
||||
.string()
|
||||
.default("claude_local")
|
||||
.refine((v) => KNOWN_ADAPTER_TYPES.has(v), {
|
||||
message: "adapterType must be one of the known adapter types",
|
||||
}),
|
||||
|
||||
/**
|
||||
* The sandbox backend to use.
|
||||
*
|
||||
* - `"sandbox-cr"` (default, alpha) — uses the kubernetes-sigs/agent-sandbox
|
||||
* Sandbox CRD (agents.x-k8s.io/v1alpha1). Creates a long-lived pod that
|
||||
* paperclip-server can exec into for multi-command adapter-install workflows.
|
||||
* Requires the agent-sandbox controller to be installed in the cluster.
|
||||
*
|
||||
* - `"job"` — uses batch/v1 Job (stable fallback). One-shot entrypoint; does
|
||||
* NOT support multi-command exec. Use this for clusters without agent-sandbox
|
||||
* installed, or when you need stable (non-alpha) k8s APIs.
|
||||
*/
|
||||
backend: z.enum(["sandbox-cr", "job"]).default("sandbox-cr"),
|
||||
})
|
||||
.refine(
|
||||
(cfg) => cfg.inCluster || (typeof cfg.kubeconfig === "string" && cfg.kubeconfig.trim().length > 0),
|
||||
{
|
||||
message:
|
||||
"kubernetes provider requires one of `inCluster` or `kubeconfig`",
|
||||
},
|
||||
);
|
||||
|
||||
export type KubernetesProviderConfig = z.infer<typeof kubernetesProviderConfigSchema>;
|
||||
|
||||
export function parseKubernetesProviderConfig(input: unknown): KubernetesProviderConfig {
|
||||
return kubernetesProviderConfigSchema.parse(input);
|
||||
}
|
||||
|
||||
export interface KubernetesLeaseMetadata {
|
||||
namespace: string;
|
||||
/** Name of the workload resource (Job name for job backend, Sandbox CR name for sandbox-cr backend). */
|
||||
jobName: string;
|
||||
podName: string | null;
|
||||
secretName: string;
|
||||
phase: "Pending" | "Running" | "Succeeded" | "Failed";
|
||||
/** Which backend provisioned this lease. */
|
||||
backend: "sandbox-cr" | "job";
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Fast-upload interceptor for the chunked-shell file transfer protocol used by
|
||||
* `@paperclipai/adapter-utils` command-managed runtimes.
|
||||
*
|
||||
* The normal path writes files through many shell execs:
|
||||
* 1. mkdir/rm/touch `<target>.paperclip-upload.b64`
|
||||
* 2. append many base64 chunks with printf
|
||||
* 3. base64-decode the temp file into the final target
|
||||
*
|
||||
* On Kubernetes each exec is a new WebSocket round trip. This state machine
|
||||
* recognizes that exact protocol, buffers the base64 chunks in the plugin
|
||||
* worker, and lets the caller flush the final payload through one exec.
|
||||
* Pattern drift or missing state falls through to the original exec path.
|
||||
*/
|
||||
import { posix as pathPosix } from "node:path";
|
||||
|
||||
const INIT_RE =
|
||||
/^mkdir -p '([^']+)' && rm -f '([^']+)\.paperclip-upload\.b64' && : > '\2\.paperclip-upload\.b64'$/;
|
||||
const CHUNK_RE =
|
||||
/^printf '%s' '([A-Za-z0-9+/]+={0,2})' >> '([^']+)\.paperclip-upload\.b64'$/;
|
||||
const FINALIZE_RE =
|
||||
/^base64 -d < '([^']+)\.paperclip-upload\.b64' > '\1' && rm -f '\1\.paperclip-upload\.b64'$/;
|
||||
|
||||
const MAX_BUFFER_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export interface FastUploadFlush {
|
||||
targetPath: string;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
export type FastUploadDecision =
|
||||
| { action: "ack"; reason: string }
|
||||
| { action: "flush"; flush: FastUploadFlush }
|
||||
| { action: "error"; message: string }
|
||||
| { action: "passthrough"; reason: string };
|
||||
|
||||
interface BufferedUpload {
|
||||
targetPath: string;
|
||||
chunks: string[];
|
||||
totalBase64Chars: number;
|
||||
sawPaddedChunk: boolean;
|
||||
}
|
||||
|
||||
export class FastUploadInterceptor {
|
||||
private readonly buffers = new Map<string, BufferedUpload>();
|
||||
|
||||
constructor(private readonly maxBufferBytes = MAX_BUFFER_BYTES) {}
|
||||
|
||||
decide(command: string): FastUploadDecision {
|
||||
const initMatch = INIT_RE.exec(command);
|
||||
if (initMatch) {
|
||||
const dir = initMatch[1];
|
||||
const targetPath = initMatch[2];
|
||||
if (pathPosix.dirname(targetPath) !== dir) {
|
||||
return { action: "passthrough", reason: "init dir/target mismatch" };
|
||||
}
|
||||
|
||||
const tempPath = `${targetPath}.paperclip-upload.b64`;
|
||||
if (this.buffers.has(tempPath)) {
|
||||
this.buffers.delete(tempPath);
|
||||
return {
|
||||
action: "error",
|
||||
message: `Fast upload already in progress for ${targetPath}; retry the upload from the beginning.`,
|
||||
};
|
||||
}
|
||||
|
||||
this.buffers.set(tempPath, {
|
||||
targetPath,
|
||||
chunks: [],
|
||||
totalBase64Chars: 0,
|
||||
sawPaddedChunk: false,
|
||||
});
|
||||
return { action: "ack", reason: `init upload to ${targetPath}` };
|
||||
}
|
||||
|
||||
const chunkMatch = CHUNK_RE.exec(command);
|
||||
if (chunkMatch) {
|
||||
const base64Chunk = chunkMatch[1];
|
||||
const targetPath = chunkMatch[2];
|
||||
const tempPath = `${targetPath}.paperclip-upload.b64`;
|
||||
const upload = this.buffers.get(tempPath);
|
||||
if (!upload) {
|
||||
return { action: "passthrough", reason: "chunk without prior init" };
|
||||
}
|
||||
if (upload.sawPaddedChunk) {
|
||||
this.buffers.delete(tempPath);
|
||||
return {
|
||||
action: "error",
|
||||
message: `Fast upload received data after a padded chunk for ${upload.targetPath}; retry the upload from the beginning.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (upload.totalBase64Chars + base64Chunk.length > (this.maxBufferBytes * 4) / 3) {
|
||||
this.buffers.delete(tempPath);
|
||||
return {
|
||||
action: "error",
|
||||
message: `Fast upload buffer cap exceeded for ${upload.targetPath}; retry the upload with a smaller payload.`,
|
||||
};
|
||||
}
|
||||
|
||||
upload.chunks.push(base64Chunk);
|
||||
upload.totalBase64Chars += base64Chunk.length;
|
||||
upload.sawPaddedChunk = base64Chunk.endsWith("=");
|
||||
return { action: "ack", reason: `buffered ${base64Chunk.length} base64 chars` };
|
||||
}
|
||||
|
||||
const finalizeMatch = FINALIZE_RE.exec(command);
|
||||
if (finalizeMatch) {
|
||||
const targetPath = finalizeMatch[1];
|
||||
const tempPath = `${targetPath}.paperclip-upload.b64`;
|
||||
const upload = this.buffers.get(tempPath);
|
||||
if (!upload) {
|
||||
return { action: "passthrough", reason: "finalize without buffered state" };
|
||||
}
|
||||
|
||||
this.buffers.delete(tempPath);
|
||||
return {
|
||||
action: "flush",
|
||||
flush: {
|
||||
targetPath: upload.targetPath,
|
||||
payload: Buffer.from(upload.chunks.join(""), "base64"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const activeUpload = this.findActiveUploadForCommand(command);
|
||||
if (activeUpload) {
|
||||
this.buffers.delete(activeUpload.tempPath);
|
||||
return {
|
||||
action: "error",
|
||||
message: `Fast upload protocol violation for ${activeUpload.upload.targetPath}; retry the upload from the beginning.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { action: "passthrough", reason: "no upload pattern" };
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.buffers.clear();
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.buffers.size;
|
||||
}
|
||||
|
||||
private findActiveUploadForCommand(command: string): { tempPath: string; upload: BufferedUpload } | null {
|
||||
for (const [tempPath, upload] of this.buffers) {
|
||||
if (command.includes(`'${tempPath}'`)) {
|
||||
return { tempPath, upload };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const ULID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
export function deriveCompanySlug(input: string): string {
|
||||
const slug = input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32)
|
||||
.replace(/-+$/, "");
|
||||
return slug.length > 0 ? slug : "company";
|
||||
}
|
||||
|
||||
export function deriveNamespaceName(prefix: string, slug: string): string {
|
||||
return `${prefix}${slug}`;
|
||||
}
|
||||
|
||||
export function newRunUlidDns(now: () => number = Date.now): string {
|
||||
const timestamp = now();
|
||||
let out = "";
|
||||
let t = timestamp;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
out = ULID_ALPHABET[t & 0x1f] + out;
|
||||
t = Math.floor(t / 32);
|
||||
}
|
||||
const randBytes = randomBytes(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
out += ULID_ALPHABET[randBytes[i] & 0x1f];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LabelsInput {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
}
|
||||
|
||||
export function paperclipLabels(input: LabelsInput): Record<string, string> {
|
||||
return {
|
||||
"paperclip.io/run-id": input.runId,
|
||||
"paperclip.io/agent-id": input.agentId,
|
||||
"paperclip.io/company-id": input.companyId,
|
||||
"paperclip.io/adapter": input.adapterType,
|
||||
"paperclip.io/managed-by": "paperclip-k8s-plugin",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { runWorker } from "@paperclipai/plugin-sdk";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const KIND_CONTEXT = "kind-paperclip";
|
||||
|
||||
export function readKindKubeconfig(): string {
|
||||
return readFileSync(join(homedir(), ".kube", "config"), "utf-8");
|
||||
}
|
||||
|
||||
export function kubectl(args: string): string {
|
||||
return execSync(`kubectl --context ${KIND_CONTEXT} ${args}`, { encoding: "utf-8" });
|
||||
}
|
||||
|
||||
export function deleteNamespaceIfExists(namespace: string): void {
|
||||
try {
|
||||
kubectl(`delete namespace ${namespace} --wait=true --timeout=60s --ignore-not-found`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* End-to-end integration test against a local kind cluster.
|
||||
*
|
||||
* PREREQUISITES (operator must perform before running this test):
|
||||
* 1. Create the kind cluster:
|
||||
* kind create cluster --name paperclip
|
||||
* 2. Pre-load the alpine image so the Job can start without network access:
|
||||
* docker pull alpine:3.20
|
||||
* docker tag alpine:3.20 localhost/paperclip-agent:latest
|
||||
* kind load docker-image localhost/paperclip-agent:latest --name paperclip
|
||||
* 3. For the sandbox-cr backend test, the agent-sandbox controller must be installed:
|
||||
* kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/install.yaml
|
||||
* And a tini-bearing image pre-loaded (e.g. the same localhost/paperclip-agent:latest
|
||||
* if it includes /usr/bin/tini and /bin/sh).
|
||||
* 4. Set the env var and run:
|
||||
* RUN_K8S_INTEGRATION_TESTS=1 pnpm test
|
||||
*
|
||||
* The namespace is derived from companySlug ("spike-e2e") + namespacePrefix
|
||||
* ("paperclip-"), resolving to "paperclip-spike-e2e".
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import plugin from "../../src/plugin.js";
|
||||
import { createKubeConfig } from "../../src/kube-client.js";
|
||||
import { execInPod } from "../../src/pod-exec.js";
|
||||
import { sandboxCrOrchestrator } from "../../src/sandbox-cr-orchestrator.js";
|
||||
import { deleteNamespaceIfExists, kubectl, readKindKubeconfig } from "./_kind-harness.js";
|
||||
|
||||
const NAMESPACE = "paperclip-spike-e2e";
|
||||
|
||||
describe("plugin-kubernetes end-to-end", () => {
|
||||
beforeAll(() => {
|
||||
if (process.env.RUN_K8S_INTEGRATION_TESTS !== "1") return;
|
||||
deleteNamespaceIfExists(NAMESPACE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (process.env.RUN_K8S_INTEGRATION_TESTS !== "1") return;
|
||||
deleteNamespaceIfExists(NAMESPACE);
|
||||
});
|
||||
|
||||
// ── Job backend (stable fallback) ─────────────────────────────────────────
|
||||
|
||||
it.runIf(process.env.RUN_K8S_INTEGRATION_TESTS === "1")(
|
||||
"[job backend] acquireLease creates tenant + Job + supporting resources; releaseLease cascade-deletes them",
|
||||
async () => {
|
||||
const kubeconfig = readKindKubeconfig();
|
||||
const config = {
|
||||
inCluster: false,
|
||||
kubeconfig,
|
||||
companySlug: "spike-e2e",
|
||||
adapterType: "claude_local",
|
||||
backend: "job",
|
||||
imageAllowList: [] as string[],
|
||||
podActivityDeadlineSec: 60,
|
||||
jobTtlSecondsAfterFinished: 60,
|
||||
};
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
runId: "r-test-e2e-job",
|
||||
companyId: "11111111-1111-1111-1111-111111111111",
|
||||
environmentId: "env-test",
|
||||
});
|
||||
|
||||
expect(lease.providerLeaseId).toMatch(/^pc-/);
|
||||
|
||||
// Verify the Job exists in the tenant namespace
|
||||
const jobs = kubectl(`get jobs -n ${NAMESPACE} -o name`);
|
||||
expect(jobs).toContain(`job.batch/${lease.providerLeaseId}`);
|
||||
|
||||
// Verify the tenant namespace has the expected supporting resources
|
||||
const all = kubectl(
|
||||
`get sa,role,rolebinding,resourcequota,limitrange,networkpolicy -n ${NAMESPACE} -o name`,
|
||||
);
|
||||
expect(all).toContain("serviceaccount/paperclip-tenant-sa");
|
||||
expect(all).toContain("role.rbac.authorization.k8s.io/paperclip-tenant-role");
|
||||
expect(all).toContain("rolebinding.rbac.authorization.k8s.io/paperclip-tenant-rb");
|
||||
expect(all).toContain("resourcequota/paperclip-quota");
|
||||
expect(all).toContain("limitrange/paperclip-limits");
|
||||
expect(all).toContain("networkpolicy.networking.k8s.io/paperclip-deny-all");
|
||||
expect(all).toContain("networkpolicy.networking.k8s.io/paperclip-egress-allow");
|
||||
|
||||
// Verify the namespace has PSS-restricted labels
|
||||
const ns = kubectl(`get namespace ${NAMESPACE} -o jsonpath='{.metadata.labels}'`);
|
||||
expect(ns).toContain("pod-security.kubernetes.io/enforce");
|
||||
expect(ns).toContain("restricted");
|
||||
|
||||
// Verify the per-run Secret exists (owned by the Job for cascade deletion)
|
||||
const secrets = kubectl(`get secrets -n ${NAMESPACE} -o name`);
|
||||
expect(secrets).toContain(`secret/${lease.providerLeaseId}-env`);
|
||||
|
||||
// Release — deletes the Job with Foreground propagation, which cascade-deletes
|
||||
// the owned Secret via owner references set at acquireLease time.
|
||||
await plugin.definition.onEnvironmentReleaseLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
leaseMetadata: lease.metadata,
|
||||
companyId: "11111111-1111-1111-1111-111111111111",
|
||||
environmentId: "env-test",
|
||||
});
|
||||
|
||||
// Allow a brief grace window for Foreground propagation to finish.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const jobsAfter = kubectl(`get jobs -n ${NAMESPACE} -o name 2>&1 || true`);
|
||||
expect(jobsAfter).not.toContain(`job.batch/${lease.providerLeaseId}`);
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
// ── Sandbox-CR backend (alpha, requires agent-sandbox controller) ──────────
|
||||
|
||||
it.runIf(process.env.RUN_K8S_INTEGRATION_TESTS === "1")(
|
||||
"[sandbox-cr backend] acquireLease creates Sandbox CR + supporting resources; pod becomes Ready; execInPod runs echo hello; releaseLease deletes CR",
|
||||
async () => {
|
||||
const kubeconfig = readKindKubeconfig();
|
||||
const config = {
|
||||
inCluster: false,
|
||||
kubeconfig,
|
||||
companySlug: "spike-e2e",
|
||||
adapterType: "claude_local",
|
||||
backend: "sandbox-cr",
|
||||
imageAllowList: [] as string[],
|
||||
podActivityDeadlineSec: 120,
|
||||
jobTtlSecondsAfterFinished: 60,
|
||||
};
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
runId: "r-test-e2e-sandbox-cr",
|
||||
companyId: "22222222-2222-2222-2222-222222222222",
|
||||
environmentId: "env-test-cr",
|
||||
});
|
||||
|
||||
expect(lease.providerLeaseId).toMatch(/^pc-/);
|
||||
|
||||
// Verify the Sandbox CR exists in the tenant namespace
|
||||
const sandboxes = kubectl(
|
||||
`get sandboxes.agents.x-k8s.io -n ${NAMESPACE} -o name 2>&1`,
|
||||
);
|
||||
expect(sandboxes).toContain(`sandbox.agents.x-k8s.io/${lease.providerLeaseId}`);
|
||||
|
||||
// Verify the per-run Secret exists (owned by the Sandbox CR)
|
||||
const secrets = kubectl(`get secrets -n ${NAMESPACE} -o name`);
|
||||
expect(secrets).toContain(`secret/${lease.providerLeaseId}-env`);
|
||||
|
||||
// Wait for the Sandbox pod to become Ready
|
||||
const kc = createKubeConfig({ inCluster: false, kubeconfig });
|
||||
const { makeKubeClients } = await import("../../src/kube-client.js");
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
await sandboxCrOrchestrator.waitForCompletion(
|
||||
clients,
|
||||
NAMESPACE,
|
||||
lease.providerLeaseId,
|
||||
{ timeoutMs: 90_000, pollMs: 3000 },
|
||||
);
|
||||
|
||||
// Resolve the pod name
|
||||
const podName = await sandboxCrOrchestrator.findPod(
|
||||
clients,
|
||||
NAMESPACE,
|
||||
lease.providerLeaseId,
|
||||
);
|
||||
expect(podName).toBeTruthy();
|
||||
|
||||
// Exec a simple echo command into the running pod
|
||||
const execResult = await execInPod(
|
||||
kc,
|
||||
NAMESPACE,
|
||||
podName!,
|
||||
"agent",
|
||||
["echo", "hello"],
|
||||
);
|
||||
|
||||
expect(execResult.exitCode).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe("hello");
|
||||
|
||||
// Release — deletes the Sandbox CR with Foreground propagation.
|
||||
await plugin.definition.onEnvironmentReleaseLease!({
|
||||
driverKey: "kubernetes",
|
||||
config,
|
||||
providerLeaseId: lease.providerLeaseId,
|
||||
leaseMetadata: lease.metadata,
|
||||
companyId: "22222222-2222-2222-2222-222222222222",
|
||||
environmentId: "env-test-cr",
|
||||
});
|
||||
|
||||
// Allow a brief grace window for Foreground propagation.
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const sandboxesAfter = kubectl(
|
||||
`get sandboxes.agents.x-k8s.io -n ${NAMESPACE} -o name 2>&1 || true`,
|
||||
);
|
||||
expect(sandboxesAfter).not.toContain(
|
||||
`sandbox.agents.x-k8s.io/${lease.providerLeaseId}`,
|
||||
);
|
||||
},
|
||||
300_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getAdapterDefaults, KNOWN_ADAPTER_TYPES } from "../../src/adapter-defaults.js";
|
||||
|
||||
describe("adapter-defaults", () => {
|
||||
it("returns defaults for claude_local", () => {
|
||||
const d = getAdapterDefaults("claude_local");
|
||||
expect(d.runtimeImage).toBe("ghcr.io/paperclipai/agent-runtime-claude:v1");
|
||||
expect(d.envKeys).toContain("ANTHROPIC_API_KEY");
|
||||
expect(d.allowFqdns).toContain("api.anthropic.com");
|
||||
expect(d.probeCommand).toEqual(["claude", "--version"]);
|
||||
});
|
||||
|
||||
it("returns defaults for codex_local", () => {
|
||||
const d = getAdapterDefaults("codex_local");
|
||||
expect(d.runtimeImage).toBe("ghcr.io/paperclipai/agent-runtime-codex:v1");
|
||||
expect(d.envKeys).toContain("OPENAI_API_KEY");
|
||||
expect(d.probeCommand).toEqual(["codex", "--version"]);
|
||||
});
|
||||
|
||||
it("throws on unknown adapter type", () => {
|
||||
expect(() => getAdapterDefaults("nonexistent_local")).toThrow(/unknown adapter type/i);
|
||||
});
|
||||
|
||||
it("KNOWN_ADAPTER_TYPES contains all 7 supported adapters", () => {
|
||||
expect(KNOWN_ADAPTER_TYPES).toEqual(
|
||||
new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"cursor_local",
|
||||
"opencode_local",
|
||||
"acpx_local",
|
||||
"pi_local",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildCiliumNetworkPolicyManifest } from "../../src/cilium-network-policy.js";
|
||||
|
||||
describe("buildCiliumNetworkPolicyManifest", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
egressAllowFqdns: ["api.anthropic.com"],
|
||||
egressAllowCidrs: [] as string[],
|
||||
};
|
||||
|
||||
it("returns a CiliumNetworkPolicy with the correct apiVersion and kind", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
expect(cnp.apiVersion).toBe("cilium.io/v2");
|
||||
expect(cnp.kind).toBe("CiliumNetworkPolicy");
|
||||
});
|
||||
|
||||
it("targets agent pods by role label", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
expect(cnp.spec.endpointSelector.matchLabels["paperclip.io/role"]).toBe("agent");
|
||||
});
|
||||
|
||||
it("includes an FQDN allow rule for each adapter FQDN", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest({
|
||||
...baseInput,
|
||||
egressAllowFqdns: ["api.anthropic.com", "api.openai.com"],
|
||||
});
|
||||
const fqdnRule = cnp.spec.egress.find((e: { toFQDNs?: { matchName: string }[] }) => e.toFQDNs);
|
||||
expect(fqdnRule).toBeDefined();
|
||||
expect(fqdnRule.toFQDNs.map((f: { matchName: string }) => f.matchName).sort()).toEqual([
|
||||
"api.anthropic.com",
|
||||
"api.openai.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("permits DNS to kube-dns explicitly so FQDN resolution can happen", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
const dnsRule = cnp.spec.egress.find((e: { toPorts?: { ports: { port: string }[] }[] }) =>
|
||||
e.toPorts?.some((tp) => tp.ports.some((p) => p.port === "53")),
|
||||
);
|
||||
expect(dnsRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes a rule for paperclip-server callback", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest(baseInput);
|
||||
const cb = cnp.spec.egress.find((e: { toEndpoints?: { matchLabels: Record<string, string> }[] }) =>
|
||||
e.toEndpoints?.some((ep) => ep.matchLabels.app === "paperclip-server"),
|
||||
);
|
||||
expect(cb).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes user-supplied CIDRs in toCIDRSet rule", () => {
|
||||
const cnp = buildCiliumNetworkPolicyManifest({
|
||||
...baseInput,
|
||||
egressAllowCidrs: ["10.0.0.0/8"],
|
||||
});
|
||||
const cidrRule = cnp.spec.egress.find((e: { toCIDRSet?: { cidr: string }[] }) => e.toCIDRSet);
|
||||
expect(cidrRule.toCIDRSet[0].cidr).toBe("10.0.0.0/8");
|
||||
expect(cidrRule.toPorts).toEqual([{ ports: [{ port: "443", protocol: "TCP" }] }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { globMatch, resolveImage } from "../../src/image-allowlist.js";
|
||||
|
||||
describe("globMatch", () => {
|
||||
it("matches exact image", () => {
|
||||
expect(globMatch("ghcr.io/paperclipai/agent-runtime-claude:v1", "ghcr.io/paperclipai/agent-runtime-claude:v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches single-character wildcard", () => {
|
||||
expect(globMatch("ghcr.io/x:v?", "ghcr.io/x:v1")).toBe(true);
|
||||
expect(globMatch("ghcr.io/x:v?", "ghcr.io/x:v12")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches multi-character wildcard", () => {
|
||||
expect(globMatch("ghcr.io/paperclipai/*:v1", "ghcr.io/paperclipai/agent-runtime-claude:v1")).toBe(true);
|
||||
expect(globMatch("ghcr.io/paperclipai/*:v1", "docker.io/other/img:v1")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not allow wildcard to span slashes by default", () => {
|
||||
expect(globMatch("ghcr.io/*:v1", "ghcr.io/paperclipai/agent-runtime-claude:v1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveImage", () => {
|
||||
const defaults = { runtimeImage: "ghcr.io/paperclipai/agent-runtime-claude:v1" };
|
||||
|
||||
it("uses adapter default when no override", () => {
|
||||
expect(resolveImage({ imageOverride: null }, defaults, { imageAllowList: [], imageRegistry: undefined })).toBe(
|
||||
"ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites registry when imageRegistry is set", () => {
|
||||
expect(
|
||||
resolveImage(
|
||||
{ imageOverride: null },
|
||||
defaults,
|
||||
{ imageAllowList: [], imageRegistry: "registry.example.com/paperclip" },
|
||||
),
|
||||
).toBe("registry.example.com/paperclip/agent-runtime-claude:v1");
|
||||
});
|
||||
|
||||
it("accepts imageOverride when in allowlist", () => {
|
||||
expect(
|
||||
resolveImage(
|
||||
{ imageOverride: "registry.example.com/mine:v2" },
|
||||
defaults,
|
||||
{ imageAllowList: ["registry.example.com/*:v2"], imageRegistry: undefined },
|
||||
),
|
||||
).toBe("registry.example.com/mine:v2");
|
||||
});
|
||||
|
||||
it("rejects imageOverride not in allowlist", () => {
|
||||
expect(() =>
|
||||
resolveImage(
|
||||
{ imageOverride: "evil.io/img:latest" },
|
||||
defaults,
|
||||
{ imageAllowList: ["registry.example.com/*"], imageRegistry: undefined },
|
||||
),
|
||||
).toThrow(/not in allowlist/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createJob, deleteJob, getJobStatus, findPodForJob, JobTimeoutError, streamPodLogs, waitForJobCompletion } from "../../src/job-orchestrator.js";
|
||||
|
||||
describe("createJob", () => {
|
||||
it("calls batch.createNamespacedJob with the manifest", async () => {
|
||||
const create = vi.fn().mockResolvedValue({ metadata: { uid: "abc-uid" } });
|
||||
const clients = { batch: { createNamespacedJob: create } };
|
||||
const jobManifest = { apiVersion: "batch/v1", kind: "Job", metadata: { name: "r-1", namespace: "ns" }, spec: { template: {} } };
|
||||
const result = await createJob(clients as never, "ns", jobManifest);
|
||||
expect(create).toHaveBeenCalledWith({ namespace: "ns", body: jobManifest });
|
||||
expect(result.uid).toBe("abc-uid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getJobStatus", () => {
|
||||
it("returns phase=Succeeded when succeeded count is 1", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { succeeded: 1, conditions: [{ type: "Complete", status: "True" }] } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Succeeded");
|
||||
expect(status.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("returns phase=Failed when failed count is >0", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { failed: 1, conditions: [{ type: "Failed", status: "True", reason: "DeadlineExceeded" }] } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Failed");
|
||||
expect(status.reason).toBe("DeadlineExceeded");
|
||||
});
|
||||
|
||||
it("returns phase=Running when active count is >0", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { active: 1 } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Running");
|
||||
});
|
||||
|
||||
it("returns phase=Pending when no active/succeeded/failed counters set", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: {} });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
const status = await getJobStatus(clients as never, "ns", "r-1");
|
||||
expect(status.phase).toBe("Pending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPodForJob", () => {
|
||||
it("lists pods by job-name label and returns the first running pod", async () => {
|
||||
const list = vi.fn().mockResolvedValue({ items: [{ metadata: { name: "r-1-xyz" }, status: { phase: "Running" } }] });
|
||||
const clients = { core: { listNamespacedPod: list } };
|
||||
const podName = await findPodForJob(clients as never, "ns", "r-1");
|
||||
expect(list).toHaveBeenCalledWith(expect.objectContaining({ namespace: "ns", labelSelector: "job-name=r-1" }));
|
||||
expect(podName).toBe("r-1-xyz");
|
||||
});
|
||||
|
||||
it("returns null when no pod is found", async () => {
|
||||
const list = vi.fn().mockResolvedValue({ items: [] });
|
||||
const clients = { core: { listNamespacedPod: list } };
|
||||
const podName = await findPodForJob(clients as never, "ns", "r-1");
|
||||
expect(podName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteJob", () => {
|
||||
it("calls batch.deleteNamespacedJob with foreground propagation", async () => {
|
||||
const del = vi.fn().mockResolvedValue({});
|
||||
const clients = { batch: { deleteNamespacedJob: del } };
|
||||
await deleteJob(clients as never, "ns", "r-1");
|
||||
expect(del).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
namespace: "ns",
|
||||
name: "r-1",
|
||||
propagationPolicy: "Foreground",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamPodLogs", () => {
|
||||
it("emits pod log response bodies as stdout because Kubernetes pod logs are combined", async () => {
|
||||
const readNamespacedPodLog = vi.fn().mockResolvedValue({ body: "hello\n" });
|
||||
const clients = { core: { readNamespacedPodLog } };
|
||||
const chunks: { stream: "stdout" | "stderr"; text: string }[] = [];
|
||||
await streamPodLogs(clients as never, "ns", "pod-1", async (stream, text) => {
|
||||
chunks.push({ stream, text });
|
||||
});
|
||||
|
||||
expect(readNamespacedPodLog).toHaveBeenCalledWith({ namespace: "ns", name: "pod-1" });
|
||||
expect(chunks).toEqual([{ stream: "stdout", text: "hello\n" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForJobCompletion", () => {
|
||||
it("throws JobTimeoutError when the deadline is exceeded", async () => {
|
||||
const get = vi.fn().mockResolvedValue({ status: { active: 1 } });
|
||||
const clients = { batch: { readNamespacedJobStatus: get } };
|
||||
await expect(
|
||||
waitForJobCompletion(clients as never, "ns", "r-1", { timeoutMs: 50, pollMs: 10 }),
|
||||
).rejects.toBeInstanceOf(JobTimeoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { createKubeConfig } from "../../src/kube-client.js";
|
||||
|
||||
describe("createKubeConfig", () => {
|
||||
it("loads from inline kubeconfig string", () => {
|
||||
const yaml = `apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: test
|
||||
cluster:
|
||||
server: https://fake.example.com
|
||||
contexts:
|
||||
- name: test
|
||||
context:
|
||||
cluster: test
|
||||
user: test
|
||||
current-context: test
|
||||
users:
|
||||
- name: test
|
||||
user:
|
||||
token: fake-token
|
||||
`;
|
||||
const kc = createKubeConfig({ inCluster: false, kubeconfig: yaml });
|
||||
expect(kc.getCurrentContext()).toBe("test");
|
||||
expect(kc.getCurrentCluster()?.server).toBe("https://fake.example.com");
|
||||
});
|
||||
|
||||
it("loads from-cluster config when inCluster=true", () => {
|
||||
const spy = vi.spyOn(KubeConfig.prototype, "loadFromCluster").mockImplementation(function (this: KubeConfig) {
|
||||
this.loadFromString(`apiVersion: v1
|
||||
kind: Config
|
||||
clusters: [{name: in-cluster, cluster: {server: 'https://kubernetes.default.svc'}}]
|
||||
contexts: [{name: in-cluster, context: {cluster: in-cluster, user: in-cluster}}]
|
||||
current-context: in-cluster
|
||||
users: [{name: in-cluster, user: {token: tok}}]`);
|
||||
});
|
||||
const kc = createKubeConfig({ inCluster: true });
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect(kc.getCurrentContext()).toBe("in-cluster");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("throws when neither inCluster nor kubeconfig string is provided", () => {
|
||||
expect(() => createKubeConfig({ inCluster: false })).toThrow(/requires/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import manifest from "../../src/manifest.js";
|
||||
|
||||
describe("manifest", () => {
|
||||
const configSchema = manifest.environmentDrivers[0]?.configSchema as {
|
||||
properties: Record<string, { const?: unknown; description?: string; maxLength?: number; pattern?: string }>;
|
||||
anyOf: Array<{
|
||||
properties?: Record<string, { const?: unknown }>;
|
||||
required?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
it("keeps namespace inputs within the Kubernetes DNS label length limit", () => {
|
||||
expect(configSchema.properties.namespacePrefix.maxLength).toBe(20);
|
||||
expect(configSchema.properties.paperclipServerNamespace.maxLength).toBe(63);
|
||||
expect(configSchema.properties.paperclipServerNamespace.pattern).toBe(
|
||||
"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
);
|
||||
expect(configSchema.properties.companySlug.maxLength).toBe(43);
|
||||
});
|
||||
|
||||
it("requires real Kubernetes credentials instead of only inCluster key presence", () => {
|
||||
expect(configSchema.properties.kubeconfig.pattern).toBe("\\S");
|
||||
expect(configSchema.anyOf).toContainEqual({
|
||||
properties: { inCluster: { const: true } },
|
||||
required: ["inCluster"],
|
||||
});
|
||||
expect(configSchema.anyOf).toContainEqual({ required: ["kubeconfig"] });
|
||||
});
|
||||
|
||||
it("documents that CIDR egress is HTTPS-only", () => {
|
||||
expect(configSchema.properties.egressAllowCidrs.description).toContain("TCP port 443");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildNetworkPolicyManifests } from "../../src/network-policy.js";
|
||||
|
||||
describe("buildNetworkPolicyManifests", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
egressAllowFqdns: [] as string[],
|
||||
egressAllowCidrs: [] as string[],
|
||||
};
|
||||
|
||||
it("produces a deny-all + egress allow pair", () => {
|
||||
const manifests = buildNetworkPolicyManifests(baseInput);
|
||||
expect(manifests).toHaveLength(2);
|
||||
expect(manifests[0].metadata.name).toBe("paperclip-deny-all");
|
||||
expect(manifests[1].metadata.name).toBe("paperclip-egress-allow");
|
||||
});
|
||||
|
||||
it("deny-all has no ingress/egress rules and applies to all pods", () => {
|
||||
const [denyAll] = buildNetworkPolicyManifests(baseInput);
|
||||
expect(denyAll.spec.podSelector).toEqual({});
|
||||
expect(denyAll.spec.policyTypes).toEqual(["Ingress", "Egress"]);
|
||||
expect(denyAll.spec.ingress).toBeUndefined();
|
||||
expect(denyAll.spec.egress).toBeUndefined();
|
||||
});
|
||||
|
||||
it("egress allow includes kube-dns and paperclip-server callback", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests(baseInput);
|
||||
const rules = egress.spec.egress;
|
||||
const dnsRule = rules.find((r: { ports?: { protocol: string; port: number }[] }) =>
|
||||
r.ports?.some((p) => p.port === 53),
|
||||
);
|
||||
expect(dnsRule).toBeDefined();
|
||||
const paperclipRule = rules.find((r: { to: { namespaceSelector?: { matchLabels?: Record<string, string> } }[] }) =>
|
||||
r.to.some((t) => t.namespaceSelector?.matchLabels?.["kubernetes.io/metadata.name"] === "paperclip"),
|
||||
);
|
||||
expect(paperclipRule).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes user-supplied CIDRs in egress allow", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests({ ...baseInput, egressAllowCidrs: ["10.0.0.0/8"] });
|
||||
const cidrRule = egress.spec.egress.find((r: { to: { ipBlock?: { cidr: string } }[]; ports?: { protocol: string; port: number }[] }) =>
|
||||
r.to.some((t) => t.ipBlock?.cidr === "10.0.0.0/8"),
|
||||
);
|
||||
expect(cidrRule).toBeDefined();
|
||||
expect(cidrRule?.ports).toEqual([{ protocol: "TCP", port: 443 }]);
|
||||
});
|
||||
|
||||
it("adds a public HTTPS fallback when standard mode receives FQDN allow-list entries", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests({ ...baseInput, egressAllowFqdns: ["api.anthropic.com"] });
|
||||
const publicHttpsRule = egress.spec.egress.find((r: { to: { ipBlock?: { cidr: string; except?: string[] } }[]; ports?: { port: number }[] }) =>
|
||||
r.to.some((t) => t.ipBlock?.cidr === "0.0.0.0/0") && r.ports?.some((p) => p.port === 443),
|
||||
);
|
||||
expect(publicHttpsRule).toBeDefined();
|
||||
expect(publicHttpsRule.to[0].ipBlock.except).toContain("10.0.0.0/8");
|
||||
});
|
||||
|
||||
it("uses paperclip-server pod label selector for callback ingress to paperclip ns", () => {
|
||||
const [, egress] = buildNetworkPolicyManifests(baseInput);
|
||||
const callbackRule = egress.spec.egress.find((r: { to: { podSelector?: { matchLabels?: Record<string, string> } }[] }) =>
|
||||
r.to.some((t) => t.podSelector?.matchLabels?.app === "paperclip-server"),
|
||||
);
|
||||
expect(callbackRule).toBeDefined();
|
||||
expect(callbackRule.ports[0].port).toBe(3100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import plugin, {
|
||||
buildSandboxExecCommand,
|
||||
buildSandboxExecShellCommand,
|
||||
deriveUploadTargetDir,
|
||||
extractAdapterEnvFromProcess,
|
||||
} from "../../src/plugin.js";
|
||||
|
||||
describe("plugin", () => {
|
||||
it("exports the kubernetes driver", () => {
|
||||
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentValidateConfig).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("validateConfig accepts inCluster=true config", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("validateConfig rejects missing auth", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.errors?.[0]).toMatch(/requires one of `inCluster`/);
|
||||
});
|
||||
|
||||
it("validateConfig normalizes defaults", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.normalizedConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
namespacePrefix: "paperclip-",
|
||||
egressMode: "standard",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
jobTtlSecondsAfterFinished: 900,
|
||||
podActivityDeadlineSec: 3600,
|
||||
adapterType: "claude_local",
|
||||
backend: "sandbox-cr", // new default
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("validateConfig accepts backend=sandbox-cr explicitly", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, backend: "sandbox-cr" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.normalizedConfig?.backend).toBe("sandbox-cr");
|
||||
});
|
||||
|
||||
it("validateConfig accepts backend=job (stable fallback)", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, backend: "job" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.normalizedConfig?.backend).toBe("job");
|
||||
});
|
||||
|
||||
it("validateConfig rejects unknown backend value", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, backend: "kata-fc" },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("onHealth returns ok", async () => {
|
||||
const result = await plugin.definition.onHealth!();
|
||||
expect(result.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("validateConfig warns about FQDN limitation in standard mode", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, adapterType: "claude_local" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings?.some((w) => w.includes("api.anthropic.com"))).toBe(true);
|
||||
});
|
||||
|
||||
it("validateConfig does NOT warn when egressMode is cilium", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
||||
driverKey: "kubernetes",
|
||||
config: { inCluster: true, adapterType: "claude_local", egressMode: "cilium" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when adapter env keys are missing from the worker process", () => {
|
||||
const warnMessages: string[] = [];
|
||||
const originalPresent = process.env.PAPERCLIP_TEST_PRESENT_KEY;
|
||||
const originalMissing = process.env.PAPERCLIP_TEST_MISSING_KEY;
|
||||
process.env.PAPERCLIP_TEST_PRESENT_KEY = "secret-value";
|
||||
delete process.env.PAPERCLIP_TEST_MISSING_KEY;
|
||||
try {
|
||||
const result = extractAdapterEnvFromProcess(
|
||||
["PAPERCLIP_TEST_PRESENT_KEY", "PAPERCLIP_TEST_MISSING_KEY"],
|
||||
(message) => warnMessages.push(message),
|
||||
);
|
||||
expect(result).toEqual({ PAPERCLIP_TEST_PRESENT_KEY: "secret-value" });
|
||||
expect(warnMessages).toHaveLength(1);
|
||||
expect(warnMessages[0]).toContain("PAPERCLIP_TEST_MISSING_KEY");
|
||||
expect(warnMessages[0]).not.toContain("secret-value");
|
||||
} finally {
|
||||
if (originalPresent === undefined) {
|
||||
delete process.env.PAPERCLIP_TEST_PRESENT_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_TEST_PRESENT_KEY = originalPresent;
|
||||
}
|
||||
if (originalMissing === undefined) {
|
||||
delete process.env.PAPERCLIP_TEST_MISSING_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_TEST_MISSING_KEY = originalMissing;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves intentionally empty adapter env values", () => {
|
||||
const warnMessages: string[] = [];
|
||||
const originalValue = process.env.PAPERCLIP_TEST_EMPTY_KEY;
|
||||
process.env.PAPERCLIP_TEST_EMPTY_KEY = "";
|
||||
try {
|
||||
const result = extractAdapterEnvFromProcess(
|
||||
["PAPERCLIP_TEST_EMPTY_KEY"],
|
||||
(message) => warnMessages.push(message),
|
||||
);
|
||||
expect(result).toEqual({ PAPERCLIP_TEST_EMPTY_KEY: "" });
|
||||
expect(warnMessages).toHaveLength(0);
|
||||
} finally {
|
||||
if (originalValue === undefined) {
|
||||
delete process.env.PAPERCLIP_TEST_EMPTY_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_TEST_EMPTY_KEY = originalValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("quotes args before passing them to /bin/sh -lc", () => {
|
||||
expect(
|
||||
buildSandboxExecShellCommand({
|
||||
args: ["git", "commit", "-m", "feat: add feature", "it's ready"],
|
||||
}),
|
||||
).toBe("'git' 'commit' '-m' 'feat: add feature' 'it'\\''s ready'");
|
||||
});
|
||||
|
||||
it("uses command verbatim when command is provided", () => {
|
||||
expect(
|
||||
buildSandboxExecShellCommand({
|
||||
command: "pnpm test -- --runInBand",
|
||||
args: ["ignored"],
|
||||
}),
|
||||
).toBe("pnpm test -- --runInBand");
|
||||
});
|
||||
|
||||
it("passes command and args directly to Kubernetes exec", () => {
|
||||
expect(
|
||||
buildSandboxExecCommand({
|
||||
command: "sh",
|
||||
args: ["-c", "printf '%s' ok"],
|
||||
}),
|
||||
).toEqual(["sh", "-c", "printf '%s' ok"]);
|
||||
});
|
||||
|
||||
it("wraps command-only execution in a login shell", () => {
|
||||
expect(
|
||||
buildSandboxExecCommand({
|
||||
command: "pnpm test -- --runInBand",
|
||||
}),
|
||||
).toEqual(["/bin/sh", "-lc", "pnpm test -- --runInBand"]);
|
||||
});
|
||||
|
||||
it("derives upload target directories for root and nested paths", () => {
|
||||
expect(deriveUploadTargetDir("/file")).toBe("/");
|
||||
expect(deriveUploadTargetDir("/workspace/file")).toBe("/workspace");
|
||||
expect(deriveUploadTargetDir("relative-file")).toBe(".");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execMock = vi.fn();
|
||||
|
||||
vi.mock("@kubernetes/client-node", () => ({
|
||||
Exec: vi.fn().mockImplementation(() => ({ exec: execMock })),
|
||||
}));
|
||||
|
||||
const { execInPod } = await import("../../src/pod-exec.js");
|
||||
|
||||
describe("execInPod", () => {
|
||||
beforeEach(() => {
|
||||
execMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns success when the Kubernetes exec status callback reports success", async () => {
|
||||
execMock.mockImplementation((_namespace, _pod, _container, _command, stdout, _stderr, _stdin, _tty, statusCallback) => {
|
||||
stdout.write("ok\n");
|
||||
stdout.end();
|
||||
_stderr.end();
|
||||
statusCallback({ status: "Success" });
|
||||
return Promise.resolve(new EventEmitter());
|
||||
});
|
||||
|
||||
const result = await execInPod({} as never, "ns", "pod-1", "agent", ["echo", "ok"]);
|
||||
expect(result).toEqual({ exitCode: 0, timedOut: false, stdout: "ok\n", stderr: "" });
|
||||
});
|
||||
|
||||
it("finishes when Kubernetes reports status without ending output streams", async () => {
|
||||
execMock.mockImplementation((_namespace, _pod, _container, _command, stdout, _stderr, _stdin, _tty, statusCallback) => {
|
||||
stdout.write("ok\n");
|
||||
statusCallback({ status: "Success" });
|
||||
return Promise.resolve(new EventEmitter());
|
||||
});
|
||||
|
||||
const result = await execInPod({} as never, "ns", "pod-1", "agent", ["echo", "ok"]);
|
||||
expect(result).toEqual({ exitCode: 0, timedOut: false, stdout: "ok\n", stderr: "" });
|
||||
});
|
||||
|
||||
it("handles output stream errors after status completion", async () => {
|
||||
execMock.mockImplementation((_namespace, _pod, _container, _command, stdout, _stderr, _stdin, _tty, statusCallback) => {
|
||||
statusCallback({ status: "Success" });
|
||||
stdout.emit("error", new Error("write after end"));
|
||||
return Promise.resolve(new EventEmitter());
|
||||
});
|
||||
|
||||
const result = await execInPod({} as never, "ns", "pod-1", "agent", ["echo", "ok"]);
|
||||
expect(result).toEqual({ exitCode: 0, timedOut: false, stdout: "", stderr: "" });
|
||||
});
|
||||
|
||||
it("returns an execution failure if the websocket closes before a status frame", async () => {
|
||||
const ws = new EventEmitter();
|
||||
execMock.mockResolvedValue(ws);
|
||||
|
||||
const resultPromise = execInPod({} as never, "ns", "pod-1", "agent", ["sleep", "1"]);
|
||||
await Promise.resolve();
|
||||
ws.emit("close", 1006, Buffer.from("connection lost"));
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stderr: expect.stringContaining("websocket closed before status frame"),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an execution failure if the exec command exceeds its deadline", async () => {
|
||||
execMock.mockResolvedValue(new EventEmitter());
|
||||
|
||||
const result = await execInPod({} as never, "ns", "pod-1", "agent", ["sleep", "60"], undefined, 5);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.stderr).toContain("Kubernetes exec timed out after 5ms");
|
||||
});
|
||||
|
||||
it("clears the timeout when websocket setup rejects", async () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
execMock.mockRejectedValue(new Error("network unreachable"));
|
||||
|
||||
await expect(
|
||||
execInPod({} as never, "ns", "pod-1", "agent", ["echo", "ok"], undefined, 1000),
|
||||
).rejects.toThrow("network unreachable");
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("wraps stdin commands with a byte-counted head prefix", async () => {
|
||||
let observedCommand: string[] | undefined;
|
||||
let observedStdin = "";
|
||||
let observedStdinFinished = false;
|
||||
|
||||
execMock.mockImplementation((_namespace, _pod, _container, command, stdout, stderr, stdin, _tty, statusCallback) => {
|
||||
observedCommand = command;
|
||||
stdin?.on("data", (chunk: Buffer) => {
|
||||
observedStdin += chunk.toString("utf8");
|
||||
});
|
||||
stdin?.on("finish", () => {
|
||||
observedStdinFinished = true;
|
||||
});
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
statusCallback({ status: "Success" });
|
||||
return Promise.resolve(new EventEmitter());
|
||||
});
|
||||
|
||||
await execInPod({} as never, "ns", "pod-1", "agent", ["base64", "-d"], "abc");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(observedCommand).toEqual(["/bin/sh", "-c", "head -c 3 | 'base64' '-d'"]);
|
||||
expect(observedStdin).toBe("abc");
|
||||
expect(observedStdinFinished).toBe(true);
|
||||
});
|
||||
|
||||
it("does not send stdin if the exec timed out before websocket setup completed", async () => {
|
||||
let resolveWebsocket: ((ws: EventEmitter) => void) | undefined;
|
||||
let observedStdin = "";
|
||||
let observedStdinFinished = false;
|
||||
const ws = Object.assign(new EventEmitter(), { close: vi.fn() });
|
||||
|
||||
execMock.mockImplementation((_namespace, _pod, _container, _command, _stdout, _stderr, stdin) => {
|
||||
stdin?.on("data", (chunk: Buffer) => {
|
||||
observedStdin += chunk.toString("utf8");
|
||||
});
|
||||
stdin?.on("finish", () => {
|
||||
observedStdinFinished = true;
|
||||
});
|
||||
return new Promise<EventEmitter>((resolve) => {
|
||||
resolveWebsocket = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const result = await execInPod({} as never, "ns", "pod-1", "agent", ["base64", "-d"], "abc", 5);
|
||||
expect(result.timedOut).toBe(true);
|
||||
|
||||
resolveWebsocket?.(ws);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
expect(observedStdin).toBe("");
|
||||
expect(observedStdinFinished).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildJobManifest } from "../../src/pod-spec-builder.js";
|
||||
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
jobName: "r-01h00000000000000000000000",
|
||||
adapterType: "claude_local",
|
||||
image: "ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
envSecretName: "r-01h00000000000000000000000-env",
|
||||
serviceAccountName: "paperclip-tenant-sa",
|
||||
labels: { "paperclip.io/run-id": "r1" },
|
||||
resources: { requests: { cpu: "250m", memory: "512Mi" }, limits: { cpu: "2", memory: "4Gi" } },
|
||||
runtimeClassName: undefined,
|
||||
activeDeadlineSec: 3600,
|
||||
ttlSecondsAfterFinished: 900,
|
||||
};
|
||||
|
||||
describe("buildJobManifest", () => {
|
||||
it("returns a Job manifest with the correct apiVersion and kind", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.apiVersion).toBe("batch/v1");
|
||||
expect(job.kind).toBe("Job");
|
||||
});
|
||||
|
||||
it("sets Job-level lifecycle controls: backoffLimit=0, ttlSecondsAfterFinished, activeDeadlineSeconds", () => {
|
||||
const job = buildJobManifest({ ...baseInput, activeDeadlineSec: 1800, ttlSecondsAfterFinished: 600 });
|
||||
expect(job.spec.backoffLimit).toBe(0);
|
||||
expect(job.spec.ttlSecondsAfterFinished).toBe(600);
|
||||
expect(job.spec.activeDeadlineSeconds).toBe(1800);
|
||||
});
|
||||
|
||||
it("sets the security context to non-root, drop ALL caps, read-only rootFS, seccomp RuntimeDefault", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const podSec = job.spec.template.spec.securityContext;
|
||||
expect(podSec.runAsNonRoot).toBe(true);
|
||||
expect(podSec.runAsUser).toBe(1000);
|
||||
expect(podSec.fsGroupChangePolicy).toBe("OnRootMismatch");
|
||||
expect(podSec.seccompProfile.type).toBe("RuntimeDefault");
|
||||
|
||||
const container = job.spec.template.spec.containers[0];
|
||||
expect(container.securityContext.runAsNonRoot).toBe(true);
|
||||
expect(container.securityContext.readOnlyRootFilesystem).toBe(true);
|
||||
expect(container.securityContext.allowPrivilegeEscalation).toBe(false);
|
||||
expect(container.securityContext.capabilities.drop).toEqual(["ALL"]);
|
||||
});
|
||||
|
||||
it("wraps the entrypoint in tini for PID 1", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const container = job.spec.template.spec.containers[0];
|
||||
expect(container.command).toEqual(["/usr/bin/tini", "--", "/usr/local/bin/paperclip-agent-shim"]);
|
||||
});
|
||||
|
||||
it("declares explicit writable emptyDir mounts for the standard agent paths", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const mounts = job.spec.template.spec.containers[0].volumeMounts;
|
||||
const mountPaths = mounts.map((m: { mountPath: string }) => m.mountPath).sort();
|
||||
expect(mountPaths).toEqual(["/home/paperclip", "/home/paperclip/.cache", "/tmp", "/workspace"]);
|
||||
|
||||
const volumes = job.spec.template.spec.volumes;
|
||||
expect(volumes.every((v: { emptyDir?: unknown }) => v.emptyDir !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("envFrom references the per-run secret", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
const envFrom = job.spec.template.spec.containers[0].envFrom;
|
||||
expect(envFrom[0].secretRef.name).toBe(baseInput.envSecretName);
|
||||
});
|
||||
|
||||
it("applies runtimeClassName when set", () => {
|
||||
const job = buildJobManifest({ ...baseInput, runtimeClassName: "kata-fc" });
|
||||
expect(job.spec.template.spec.runtimeClassName).toBe("kata-fc");
|
||||
});
|
||||
|
||||
it("does not set runtimeClassName when unset", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.spec.template.spec.runtimeClassName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets pod restartPolicy=Never (required for Job)", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.spec.template.spec.restartPolicy).toBe("Never");
|
||||
});
|
||||
|
||||
it("disables automountServiceAccountToken to avoid exposing an unnecessary SA token", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.spec.template.spec.automountServiceAccountToken).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the provided labels to both Job metadata and pod template", () => {
|
||||
const job = buildJobManifest(baseInput);
|
||||
expect(job.metadata.labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(job.spec.template.metadata.labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(job.spec.template.metadata.labels["paperclip.io/role"]).toBe("agent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildSandboxCrManifest } from "../../src/sandbox-cr-builder.js";
|
||||
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
sandboxName: "pc-01h00000000000000000000000",
|
||||
adapterType: "claude_local",
|
||||
image: "ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
envSecretName: "pc-01h00000000000000000000000-env",
|
||||
serviceAccountName: "paperclip-tenant-sa",
|
||||
labels: { "paperclip.io/run-id": "r1" },
|
||||
resources: {
|
||||
requests: { cpu: "250m", memory: "512Mi" },
|
||||
limits: { cpu: "2", memory: "4Gi" },
|
||||
},
|
||||
runtimeClassName: undefined,
|
||||
};
|
||||
|
||||
describe("buildSandboxCrManifest", () => {
|
||||
it("returns a Sandbox CR with the correct apiVersion and kind", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.apiVersion).toBe("agents.x-k8s.io/v1alpha1");
|
||||
expect(cr.kind).toBe("Sandbox");
|
||||
});
|
||||
|
||||
it("sets metadata name and namespace correctly", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.metadata.name).toBe(baseInput.sandboxName);
|
||||
expect(cr.metadata.namespace).toBe(baseInput.namespace);
|
||||
});
|
||||
|
||||
it("does NOT set ownerReferences (out-of-cluster server, explicit release path)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.metadata.ownerReferences).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets restartPolicy=Always on the pod template (required for long-lived Sandbox pod)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.restartPolicy).toBe("Always");
|
||||
});
|
||||
|
||||
it("uses sleep-infinity entrypoint via Tini for multi-command exec", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const container = cr.spec.podTemplate.spec.containers[0];
|
||||
expect(container.command).toEqual([
|
||||
"/usr/bin/tini",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"sleep infinity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies the same security baseline as Job backend (non-root, drop ALL, RO rootFS, seccomp)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const podSec = cr.spec.podTemplate.spec.securityContext;
|
||||
expect(podSec.runAsNonRoot).toBe(true);
|
||||
expect(podSec.runAsUser).toBe(1000);
|
||||
expect(podSec.fsGroupChangePolicy).toBe("OnRootMismatch");
|
||||
expect(podSec.seccompProfile.type).toBe("RuntimeDefault");
|
||||
|
||||
const container = cr.spec.podTemplate.spec.containers[0];
|
||||
expect(container.securityContext.runAsNonRoot).toBe(true);
|
||||
expect(container.securityContext.readOnlyRootFilesystem).toBe(true);
|
||||
expect(container.securityContext.allowPrivilegeEscalation).toBe(false);
|
||||
expect(container.securityContext.capabilities.drop).toEqual(["ALL"]);
|
||||
});
|
||||
|
||||
it("disables automountServiceAccountToken", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.automountServiceAccountToken).toBe(false);
|
||||
});
|
||||
|
||||
it("declares emptyDir volume mounts for standard agent paths", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const mounts = cr.spec.podTemplate.spec.containers[0].volumeMounts;
|
||||
const mountPaths = mounts
|
||||
.map((m: { mountPath: string }) => m.mountPath)
|
||||
.sort();
|
||||
expect(mountPaths).toEqual([
|
||||
"/home/paperclip",
|
||||
"/home/paperclip/.cache",
|
||||
"/tmp",
|
||||
"/workspace",
|
||||
]);
|
||||
|
||||
const volumes = cr.spec.podTemplate.spec.volumes;
|
||||
expect(
|
||||
volumes.every((v: { emptyDir?: unknown }) => v.emptyDir !== undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("envFrom references the per-run secret", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
const envFrom = cr.spec.podTemplate.spec.containers[0].envFrom;
|
||||
expect(envFrom[0].secretRef.name).toBe(baseInput.envSecretName);
|
||||
});
|
||||
|
||||
it("applies runtimeClassName when set", () => {
|
||||
const cr = buildSandboxCrManifest({
|
||||
...baseInput,
|
||||
runtimeClassName: "kata-fc",
|
||||
});
|
||||
expect(cr.spec.podTemplate.spec.runtimeClassName).toBe("kata-fc");
|
||||
});
|
||||
|
||||
it("does not set runtimeClassName when unset", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.runtimeClassName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies provided labels to CR metadata and pod template labels (with role=agent added)", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.metadata.labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(
|
||||
cr.spec.podTemplate.metadata.labels["paperclip.io/run-id"],
|
||||
).toBe("r1");
|
||||
expect(cr.spec.podTemplate.metadata.labels["paperclip.io/role"]).toBe(
|
||||
"agent",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies imagePullSecrets when provided", () => {
|
||||
const cr = buildSandboxCrManifest({
|
||||
...baseInput,
|
||||
imagePullSecrets: ["my-pull-secret"],
|
||||
});
|
||||
expect(cr.spec.podTemplate.spec.imagePullSecrets).toEqual([
|
||||
{ name: "my-pull-secret" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not set imagePullSecrets when not provided", () => {
|
||||
const cr = buildSandboxCrManifest(baseInput);
|
||||
expect(cr.spec.podTemplate.spec.imagePullSecrets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
createSandboxCr,
|
||||
deleteSandboxCr,
|
||||
getSandboxCrStatus,
|
||||
findPodForSandbox,
|
||||
SandboxCrTimeoutError,
|
||||
waitForSandboxReady,
|
||||
} from "../../src/sandbox-cr-orchestrator.js";
|
||||
|
||||
const SANDBOX_GROUP = "agents.x-k8s.io";
|
||||
const SANDBOX_VERSION = "v1alpha1";
|
||||
const SANDBOX_PLURAL = "sandboxes";
|
||||
|
||||
// Helpers to build mock CR objects with given phase
|
||||
function makeCr(phase: string, podName?: string): Record<string, unknown> {
|
||||
return {
|
||||
metadata: { uid: "sandbox-uid-123" },
|
||||
status: {
|
||||
phase,
|
||||
...(podName ? { podName } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSandboxCr", () => {
|
||||
it("calls custom.createNamespacedCustomObject with the correct params", async () => {
|
||||
const create = vi.fn().mockResolvedValue({ metadata: { uid: "test-uid" } });
|
||||
const clients = { custom: { createNamespacedCustomObject: create } };
|
||||
const manifest = {
|
||||
apiVersion: "agents.x-k8s.io/v1alpha1",
|
||||
kind: "Sandbox",
|
||||
metadata: { name: "pc-abc", namespace: "paperclip-acme" },
|
||||
};
|
||||
const result = await createSandboxCr(clients as never, "paperclip-acme", manifest);
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace: "paperclip-acme",
|
||||
plural: SANDBOX_PLURAL,
|
||||
body: manifest,
|
||||
});
|
||||
expect(result.uid).toBe("test-uid");
|
||||
});
|
||||
|
||||
it("throws if the API response has no UID", async () => {
|
||||
const create = vi.fn().mockResolvedValue({ metadata: {} });
|
||||
const clients = { custom: { createNamespacedCustomObject: create } };
|
||||
await expect(
|
||||
createSandboxCr(clients as never, "ns", {}),
|
||||
).rejects.toThrow("Sandbox CR created without a UID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSandboxCrStatus", () => {
|
||||
it("maps phase=Ready to SandboxStatus.phase=Running with active=1", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Ready"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Running");
|
||||
expect(status.active).toBe(1);
|
||||
expect(status.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("maps phase=Pending to SandboxStatus.phase=Pending", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Pending");
|
||||
expect(status.active).toBe(0);
|
||||
});
|
||||
|
||||
it("maps phase=Failed to SandboxStatus.phase=Failed with failed=1", async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
metadata: { uid: "uid-1" },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
conditions: [
|
||||
{ type: "Failed", reason: "ImagePullFailed", message: "no image" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Failed");
|
||||
expect(status.failed).toBe(1);
|
||||
expect(status.reason).toBe("ImagePullFailed");
|
||||
});
|
||||
|
||||
it("maps phase=Terminating to SandboxStatus.phase=Running with reason=Terminating", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Terminating"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await getSandboxCrStatus(clients as never, "ns", "pc-abc");
|
||||
expect(status.phase).toBe("Running");
|
||||
expect(status.reason).toBe("Terminating");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPodForSandbox", () => {
|
||||
it("returns status.podName from the Sandbox CR when set", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Ready", "pc-abc-pod-xyz"));
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: get },
|
||||
core: { listNamespacedPod: vi.fn() },
|
||||
};
|
||||
const podName = await findPodForSandbox(clients as never, "ns", "pc-abc");
|
||||
expect(podName).toBe("pc-abc-pod-xyz");
|
||||
// Should NOT have called listNamespacedPod (primary path succeeded)
|
||||
expect(clients.core.listNamespacedPod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to pod listing when status.podName is absent", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending")); // no podName
|
||||
const list = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: "pc-abc-001", labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" } },
|
||||
status: { phase: "Running" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: get },
|
||||
core: { listNamespacedPod: list },
|
||||
};
|
||||
const podName = await findPodForSandbox(clients as never, "ns", "pc-abc");
|
||||
// name starts with "pc-abc" → matched by prefix heuristic
|
||||
expect(podName).toBe("pc-abc-001");
|
||||
});
|
||||
|
||||
it("returns null when no pod is found in fallback", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending"));
|
||||
const list = vi.fn().mockResolvedValue({ items: [] });
|
||||
const clients = {
|
||||
custom: { getNamespacedCustomObject: get },
|
||||
core: { listNamespacedPod: list },
|
||||
};
|
||||
const podName = await findPodForSandbox(clients as never, "ns", "pc-abc");
|
||||
expect(podName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSandboxCr", () => {
|
||||
it("calls custom.deleteNamespacedCustomObject with Foreground propagation", async () => {
|
||||
const del = vi.fn().mockResolvedValue({});
|
||||
const clients = { custom: { deleteNamespacedCustomObject: del } };
|
||||
await deleteSandboxCr(clients as never, "ns", "pc-abc");
|
||||
expect(del).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace: "ns",
|
||||
plural: SANDBOX_PLURAL,
|
||||
name: "pc-abc",
|
||||
propagationPolicy: "Foreground",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForSandboxReady", () => {
|
||||
it("resolves immediately when Sandbox is already Ready", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Ready"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await waitForSandboxReady(
|
||||
clients as never,
|
||||
"ns",
|
||||
"pc-abc",
|
||||
{ timeoutMs: 5000, pollMs: 10 },
|
||||
);
|
||||
expect(status.phase).toBe("Running"); // Ready maps to Running
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("polls until Ready", async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeCr("Pending"))
|
||||
.mockResolvedValueOnce(makeCr("Pending"))
|
||||
.mockResolvedValueOnce(makeCr("Ready"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
const status = await waitForSandboxReady(
|
||||
clients as never,
|
||||
"ns",
|
||||
"pc-abc",
|
||||
{ timeoutMs: 5000, pollMs: 10 },
|
||||
);
|
||||
expect(status.phase).toBe("Running");
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("throws SandboxCrTimeoutError when deadline is exceeded", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Pending"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
await expect(
|
||||
waitForSandboxReady(clients as never, "ns", "pc-abc", {
|
||||
timeoutMs: 50,
|
||||
pollMs: 10,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SandboxCrTimeoutError);
|
||||
});
|
||||
|
||||
it("throws an error describing the failure when Sandbox fails", async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
metadata: { uid: "u1" },
|
||||
status: { phase: "Failed", conditions: [{ type: "Failed", reason: "OOMKilled" }] },
|
||||
});
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
await expect(
|
||||
waitForSandboxReady(clients as never, "ns", "pc-abc", {
|
||||
timeoutMs: 5000,
|
||||
pollMs: 10,
|
||||
}),
|
||||
).rejects.toThrow(/failed.*OOMKilled/i);
|
||||
});
|
||||
|
||||
it("fails fast when Sandbox starts terminating before it is ready", async () => {
|
||||
const get = vi.fn().mockResolvedValue(makeCr("Terminating"));
|
||||
const clients = { custom: { getNamespacedCustomObject: get } };
|
||||
await expect(
|
||||
waitForSandboxReady(clients as never, "ns", "pc-abc", {
|
||||
timeoutMs: 5000,
|
||||
pollMs: 10,
|
||||
}),
|
||||
).rejects.toThrow(/terminating before it became ready/i);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createPerRunSecret } from "../../src/secret-manager.js";
|
||||
|
||||
describe("createPerRunSecret", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
secretName: "r-abcd-env",
|
||||
runId: "r-abcd",
|
||||
ownerKind: "Job",
|
||||
ownerApiVersion: "batch/v1",
|
||||
ownerName: "r-abcd",
|
||||
ownerUid: "11111111-1111-1111-1111-111111111111",
|
||||
bootstrapToken: "tok-xyz",
|
||||
adapterEnv: { ANTHROPIC_API_KEY: "sk-test" },
|
||||
};
|
||||
|
||||
it("creates a Secret with the correct name and namespace", async () => {
|
||||
const created: { body: Record<string, unknown> }[] = [];
|
||||
const clients = {
|
||||
core: { createNamespacedSecret: vi.fn(async (args: { body: Record<string, unknown> }) => { created.push(args); }) },
|
||||
};
|
||||
await createPerRunSecret(clients as never, baseInput);
|
||||
expect(clients.core.createNamespacedSecret).toHaveBeenCalledOnce();
|
||||
const body = created[0].body as { metadata: { name: string; namespace: string } };
|
||||
expect(body.metadata.name).toBe("r-abcd-env");
|
||||
expect(body.metadata.namespace).toBe("paperclip-acme");
|
||||
});
|
||||
|
||||
it("includes BOOTSTRAP_TOKEN and adapter env keys in stringData", async () => {
|
||||
const created: { body: Record<string, unknown> }[] = [];
|
||||
const clients = {
|
||||
core: { createNamespacedSecret: vi.fn(async (args: { body: Record<string, unknown> }) => { created.push(args); }) },
|
||||
};
|
||||
await createPerRunSecret(clients as never, baseInput);
|
||||
const body = created[0].body as { stringData: Record<string, string> };
|
||||
expect(body.stringData.BOOTSTRAP_TOKEN).toBe("tok-xyz");
|
||||
expect(body.stringData.ANTHROPIC_API_KEY).toBe("sk-test");
|
||||
});
|
||||
|
||||
it("sets ownerReferences to the owner resource for cascade delete", async () => {
|
||||
const created: { body: Record<string, unknown> }[] = [];
|
||||
const clients = {
|
||||
core: { createNamespacedSecret: vi.fn(async (args: { body: Record<string, unknown> }) => { created.push(args); }) },
|
||||
};
|
||||
await createPerRunSecret(clients as never, baseInput);
|
||||
const body = created[0].body as { metadata: { ownerReferences: { uid: string; controller: boolean }[] } };
|
||||
expect(body.metadata.ownerReferences).toHaveLength(1);
|
||||
expect(body.metadata.ownerReferences[0].uid).toBe("11111111-1111-1111-1111-111111111111");
|
||||
expect(body.metadata.ownerReferences[0].controller).toBe(true);
|
||||
});
|
||||
|
||||
it("throws if adapterEnv contains BOOTSTRAP_TOKEN", async () => {
|
||||
const clients = { core: { createNamespacedSecret: vi.fn() } };
|
||||
await expect(
|
||||
createPerRunSecret(clients as never, {
|
||||
...baseInput,
|
||||
adapterEnv: { BOOTSTRAP_TOKEN: "evil" },
|
||||
}),
|
||||
).rejects.toThrow(/BOOTSTRAP_TOKEN/);
|
||||
});
|
||||
|
||||
it("throws if ownerUid is empty", async () => {
|
||||
const clients = { core: { createNamespacedSecret: vi.fn() } };
|
||||
await expect(
|
||||
createPerRunSecret(clients as never, { ...baseInput, ownerUid: "" }),
|
||||
).rejects.toThrow(/ownerUid/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ensureTenant } from "../../src/tenant-orchestrator.js";
|
||||
|
||||
function makeMockClients() {
|
||||
const calls: { kind: string; name: string; namespace?: string; body?: unknown }[] = [];
|
||||
function track(kind: string) {
|
||||
return vi.fn(async (...args: unknown[]) => {
|
||||
const arg = (args[0] ?? {}) as { name?: string; namespace?: string; body?: unknown };
|
||||
calls.push({ kind, name: arg.name ?? "", namespace: arg.namespace, body: arg.body });
|
||||
return { body: arg.body };
|
||||
});
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
core: {
|
||||
createNamespace: track("Namespace"),
|
||||
readNamespacedServiceAccount: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedServiceAccount: track("ServiceAccount"),
|
||||
replaceNamespacedServiceAccount: track("ServiceAccountReplace"),
|
||||
readNamespacedResourceQuota: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedResourceQuota: track("ResourceQuota"),
|
||||
replaceNamespacedResourceQuota: track("ResourceQuotaReplace"),
|
||||
readNamespacedLimitRange: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedLimitRange: track("LimitRange"),
|
||||
replaceNamespacedLimitRange: track("LimitRangeReplace"),
|
||||
readNamespace: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
replaceNamespace: track("NamespaceReplace"),
|
||||
},
|
||||
rbac: {
|
||||
readNamespacedRole: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedRole: track("Role"),
|
||||
replaceNamespacedRole: track("RoleReplace"),
|
||||
readNamespacedRoleBinding: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedRoleBinding: track("RoleBinding"),
|
||||
replaceNamespacedRoleBinding: track("RoleBindingReplace"),
|
||||
},
|
||||
networking: {
|
||||
readNamespacedNetworkPolicy: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedNetworkPolicy: track("NetworkPolicy"),
|
||||
replaceNamespacedNetworkPolicy: track("NetworkPolicyReplace"),
|
||||
deleteNamespacedNetworkPolicy: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
},
|
||||
custom: {
|
||||
getNamespacedCustomObject: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
createNamespacedCustomObject: track("CiliumNetworkPolicy"),
|
||||
replaceNamespacedCustomObject: track("CiliumNetworkPolicyReplace"),
|
||||
deleteNamespacedCustomObject: vi.fn().mockRejectedValue({ code: 404 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ensureTenant", () => {
|
||||
const baseInput = {
|
||||
namespace: "paperclip-acme",
|
||||
companyId: "11111111-1111-1111-1111-111111111111",
|
||||
paperclipServerNamespace: "paperclip",
|
||||
serviceAccountAnnotations: {},
|
||||
egressMode: "standard" as const,
|
||||
egressAllowFqdns: ["api.anthropic.com"],
|
||||
egressAllowCidrs: [] as string[],
|
||||
resourceQuota: { pods: "20", requestsCpu: "5", requestsMemory: "20Gi", limitsCpu: "20", limitsMemory: "80Gi" },
|
||||
};
|
||||
|
||||
it("creates all required resources in the correct order on a fresh tenant", async () => {
|
||||
const clients = makeMockClients();
|
||||
await ensureTenant(clients as never, baseInput);
|
||||
const order = clients.calls.map((c) => c.kind);
|
||||
expect(order).toEqual([
|
||||
"Namespace",
|
||||
"ServiceAccount",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ResourceQuota",
|
||||
"LimitRange",
|
||||
"NetworkPolicy",
|
||||
"NetworkPolicy",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a CiliumNetworkPolicy instead of standard egress when egressMode=cilium", async () => {
|
||||
const clients = makeMockClients();
|
||||
await ensureTenant(clients as never, { ...baseInput, egressMode: "cilium" });
|
||||
const cnpCall = clients.calls.find((c) => c.kind === "CiliumNetworkPolicy");
|
||||
expect(cnpCall).toBeDefined();
|
||||
const npCalls = clients.calls.filter((c) => c.kind === "NetworkPolicy");
|
||||
expect(npCalls).toHaveLength(1);
|
||||
expect((npCalls[0].body as { metadata: { name: string } }).metadata.name).toBe("paperclip-deny-all");
|
||||
});
|
||||
|
||||
it("applies serviceAccountAnnotations to the ServiceAccount", async () => {
|
||||
const clients = makeMockClients();
|
||||
await ensureTenant(clients as never, {
|
||||
...baseInput,
|
||||
serviceAccountAnnotations: { "eks.amazonaws.com/role-arn": "arn:aws:iam::123:role/paperclip" },
|
||||
});
|
||||
const saCall = clients.calls.find((c) => c.kind === "ServiceAccount");
|
||||
const sa = saCall!.body as { metadata: { annotations: Record<string, string> } };
|
||||
expect(sa.metadata.annotations["eks.amazonaws.com/role-arn"]).toBe("arn:aws:iam::123:role/paperclip");
|
||||
});
|
||||
|
||||
it("reconciles a namespace that already exists", async () => {
|
||||
const clients = makeMockClients();
|
||||
clients.core.readNamespace.mockResolvedValue({
|
||||
metadata: {
|
||||
name: baseInput.namespace,
|
||||
resourceVersion: "rv-namespace",
|
||||
labels: { "operator.example.com/team": "infra" },
|
||||
},
|
||||
});
|
||||
await ensureTenant(clients as never, baseInput);
|
||||
expect(clients.core.createNamespace).not.toHaveBeenCalled();
|
||||
expect(clients.core.replaceNamespace).toHaveBeenCalledWith({
|
||||
name: baseInput.namespace,
|
||||
body: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
resourceVersion: "rv-namespace",
|
||||
labels: expect.objectContaining({
|
||||
"operator.example.com/team": "infra",
|
||||
"paperclip.io/company-id": baseInput.companyId,
|
||||
"paperclip.io/managed-by": "paperclip-k8s-plugin",
|
||||
"pod-security.kubernetes.io/enforce": "restricted",
|
||||
"pod-security.kubernetes.io/audit": "restricted",
|
||||
"pod-security.kubernetes.io/warn": "restricted",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles existing managed resources with the latest desired manifests", async () => {
|
||||
const clients = makeMockClients();
|
||||
const existing = { metadata: { resourceVersion: "rv-1" } };
|
||||
clients.core.readNamespace.mockResolvedValue({ metadata: { name: baseInput.namespace, resourceVersion: "rv-ns" } });
|
||||
clients.core.readNamespacedServiceAccount.mockResolvedValue(existing);
|
||||
clients.rbac.readNamespacedRole.mockResolvedValue(existing);
|
||||
clients.rbac.readNamespacedRoleBinding.mockResolvedValue(existing);
|
||||
clients.core.readNamespacedResourceQuota.mockResolvedValue(existing);
|
||||
clients.core.readNamespacedLimitRange.mockResolvedValue(existing);
|
||||
clients.networking.readNamespacedNetworkPolicy.mockResolvedValue(existing);
|
||||
|
||||
await ensureTenant(clients as never, {
|
||||
...baseInput,
|
||||
serviceAccountAnnotations: { "eks.amazonaws.com/role-arn": "arn:aws:iam::123:role/paperclip" },
|
||||
resourceQuota: { ...baseInput.resourceQuota, pods: "25" },
|
||||
});
|
||||
|
||||
expect(clients.core.replaceNamespacedServiceAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
annotations: { "eks.amazonaws.com/role-arn": "arn:aws:iam::123:role/paperclip" },
|
||||
resourceVersion: "rv-1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(clients.core.replaceNamespacedResourceQuota).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
metadata: expect.objectContaining({ resourceVersion: "rv-1" }),
|
||||
spec: expect.objectContaining({ hard: expect.objectContaining({ pods: "25" }) }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(clients.networking.replaceNamespacedNetworkPolicy).toHaveBeenCalled();
|
||||
expect(clients.core.replaceNamespace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
resourceVersion: "rv-ns",
|
||||
labels: expect.objectContaining({
|
||||
"pod-security.kubernetes.io/enforce": "restricted",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes stale standard egress NetworkPolicy when cilium mode is selected", async () => {
|
||||
const clients = makeMockClients();
|
||||
await ensureTenant(clients as never, { ...baseInput, egressMode: "cilium" });
|
||||
expect(clients.networking.deleteNamespacedNetworkPolicy).toHaveBeenCalledWith({
|
||||
namespace: baseInput.namespace,
|
||||
name: "paperclip-egress-allow",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles concurrent first-run create conflicts by rereading and replacing managed resources", async () => {
|
||||
const clients = makeMockClients();
|
||||
const existing = { metadata: { resourceVersion: "rv-race" } };
|
||||
clients.core.createNamespace.mockRejectedValueOnce({ code: 409 });
|
||||
clients.core.readNamespace
|
||||
.mockRejectedValueOnce({ code: 404 })
|
||||
.mockResolvedValue({ metadata: { resourceVersion: "rv-namespace-race" } });
|
||||
clients.core.readNamespacedServiceAccount
|
||||
.mockRejectedValueOnce({ code: 404 })
|
||||
.mockResolvedValue(existing);
|
||||
clients.core.createNamespacedServiceAccount.mockRejectedValueOnce({ code: 409 });
|
||||
|
||||
await ensureTenant(clients as never, baseInput);
|
||||
|
||||
expect(clients.core.createNamespace).toHaveBeenCalled();
|
||||
expect(clients.core.replaceNamespace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
metadata: expect.objectContaining({ resourceVersion: "rv-namespace-race" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(clients.core.replaceNamespacedServiceAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
metadata: expect.objectContaining({ resourceVersion: "rv-race" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { kubernetesProviderConfigSchema, parseKubernetesProviderConfig } from "../../src/types.js";
|
||||
|
||||
describe("kubernetesProviderConfigSchema", () => {
|
||||
it("accepts inCluster=true with no kubeconfig", () => {
|
||||
const parsed = parseKubernetesProviderConfig({ inCluster: true });
|
||||
expect(parsed.inCluster).toBe(true);
|
||||
expect(parsed.namespacePrefix).toBe("paperclip-");
|
||||
expect(parsed.paperclipServerNamespace).toBe("paperclip");
|
||||
expect(parsed.imageAllowList).toEqual([]);
|
||||
expect(parsed.egressMode).toBe("standard");
|
||||
expect(parsed.jobTtlSecondsAfterFinished).toBe(900);
|
||||
});
|
||||
|
||||
it("accepts inline kubeconfig", () => {
|
||||
const parsed = parseKubernetesProviderConfig({
|
||||
inCluster: false,
|
||||
kubeconfig: "apiVersion: v1\nkind: Config\n",
|
||||
});
|
||||
expect(parsed.kubeconfig).toContain("apiVersion");
|
||||
});
|
||||
|
||||
it("rejects when neither inCluster nor any kubeconfig source is set", () => {
|
||||
expect(() => parseKubernetesProviderConfig({ inCluster: false })).toThrow(
|
||||
/requires one of `inCluster` or `kubeconfig`/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid companySlug", () => {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: true, companySlug: "INVALID UPPER" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("bounds namespacePrefix and companySlug so their combination fits a Kubernetes namespace", () => {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: true, namespacePrefix: "a".repeat(21) }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: true, companySlug: "a".repeat(44) }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a custom paperclip-server namespace", () => {
|
||||
const parsed = parseKubernetesProviderConfig({
|
||||
inCluster: true,
|
||||
paperclipServerNamespace: "paperclip-prod",
|
||||
});
|
||||
expect(parsed.paperclipServerNamespace).toBe("paperclip-prod");
|
||||
});
|
||||
|
||||
it("rejects invalid paperclip-server namespace values", () => {
|
||||
for (const namespace of ["Paperclip", "paperclip_", "-paperclip", "paperclip-"]) {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({
|
||||
inCluster: true,
|
||||
paperclipServerNamespace: namespace,
|
||||
}),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects whitespace-only kubeconfig", () => {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: false, kubeconfig: " " }),
|
||||
).toThrow(/requires one of `inCluster` or `kubeconfig`/);
|
||||
});
|
||||
|
||||
it("rejects egressAllowCidrs entries that are not valid CIDR", () => {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: true, egressAllowCidrs: ["not-a-cidr"] }),
|
||||
).toThrow(/CIDR/i);
|
||||
});
|
||||
|
||||
it("rejects CIDRs with invalid octets or prefixes", () => {
|
||||
for (const cidr of ["999.0.0.0/8", "10.0.0.0/99", "10.0.0/24"]) {
|
||||
expect(() =>
|
||||
parseKubernetesProviderConfig({ inCluster: true, egressAllowCidrs: [cidr] }),
|
||||
).toThrow(/CIDR/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FastUploadInterceptor } from "../../src/upload-interceptor.js";
|
||||
|
||||
describe("FastUploadInterceptor", () => {
|
||||
it("collapses the adapter-utils chunked upload protocol into one flush", () => {
|
||||
const interceptor = new FastUploadInterceptor();
|
||||
const target = "/workspace/.paperclip-runtime/skills.tar";
|
||||
const chunkA = Buffer.from("hello ").toString("base64").slice(0, 4);
|
||||
const chunkB = Buffer.from("hello ").toString("base64").slice(4) + Buffer.from("world").toString("base64");
|
||||
|
||||
expect(
|
||||
interceptor.decide(
|
||||
`mkdir -p '/workspace/.paperclip-runtime' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`,
|
||||
),
|
||||
).toMatchObject({ action: "ack" });
|
||||
expect(interceptor.pendingCount).toBe(1);
|
||||
|
||||
expect(
|
||||
interceptor.decide(`printf '%s' '${chunkA}' >> '${target}.paperclip-upload.b64'`),
|
||||
).toMatchObject({ action: "ack" });
|
||||
expect(
|
||||
interceptor.decide(`printf '%s' '${chunkB}' >> '${target}.paperclip-upload.b64'`),
|
||||
).toMatchObject({ action: "ack" });
|
||||
|
||||
const decision = interceptor.decide(
|
||||
`base64 -d < '${target}.paperclip-upload.b64' > '${target}' && rm -f '${target}.paperclip-upload.b64'`,
|
||||
);
|
||||
expect(decision.action).toBe("flush");
|
||||
if (decision.action !== "flush") throw new Error("expected flush");
|
||||
expect(decision.flush.targetPath).toBe(target);
|
||||
expect(decision.flush.payload.toString("utf8")).toBe("hello world");
|
||||
expect(interceptor.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("passes through chunks and finalizers without a matching init", () => {
|
||||
const interceptor = new FastUploadInterceptor();
|
||||
const target = "/workspace/file.bin";
|
||||
|
||||
expect(
|
||||
interceptor.decide(`printf '%s' 'aGVsbG8=' >> '${target}.paperclip-upload.b64'`),
|
||||
).toMatchObject({ action: "passthrough", reason: "chunk without prior init" });
|
||||
expect(
|
||||
interceptor.decide(
|
||||
`base64 -d < '${target}.paperclip-upload.b64' > '${target}' && rm -f '${target}.paperclip-upload.b64'`,
|
||||
),
|
||||
).toMatchObject({ action: "passthrough", reason: "finalize without buffered state" });
|
||||
});
|
||||
|
||||
it("fails fast when an unrecognized command targets an active upload", () => {
|
||||
const interceptor = new FastUploadInterceptor();
|
||||
const target = "/workspace/file.bin";
|
||||
|
||||
expect(
|
||||
interceptor.decide(
|
||||
`mkdir -p '/workspace' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`,
|
||||
),
|
||||
).toMatchObject({ action: "ack" });
|
||||
|
||||
const decision = interceptor.decide(`printf '%s' 'aGVs=bG8=' >> '${target}.paperclip-upload.b64'`);
|
||||
expect(decision).toMatchObject({
|
||||
action: "error",
|
||||
message: expect.stringContaining("Fast upload protocol violation"),
|
||||
});
|
||||
expect(interceptor.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("fails fast when data arrives after a padded chunk", () => {
|
||||
const interceptor = new FastUploadInterceptor();
|
||||
const target = "/workspace/file.bin";
|
||||
|
||||
expect(
|
||||
interceptor.decide(
|
||||
`mkdir -p '/workspace' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`,
|
||||
),
|
||||
).toMatchObject({ action: "ack" });
|
||||
expect(
|
||||
interceptor.decide(`printf '%s' 'aGVs=' >> '${target}.paperclip-upload.b64'`),
|
||||
).toMatchObject({ action: "ack" });
|
||||
|
||||
const decision = interceptor.decide(`printf '%s' 'bG8=' >> '${target}.paperclip-upload.b64'`);
|
||||
expect(decision).toMatchObject({
|
||||
action: "error",
|
||||
message: expect.stringContaining("received data after a padded chunk"),
|
||||
});
|
||||
expect(interceptor.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("falls through when the init command does not match the target parent directory", () => {
|
||||
const interceptor = new FastUploadInterceptor();
|
||||
|
||||
expect(
|
||||
interceptor.decide(
|
||||
"mkdir -p '/tmp' && rm -f '/workspace/file.bin.paperclip-upload.b64' && : > '/workspace/file.bin.paperclip-upload.b64'",
|
||||
),
|
||||
).toMatchObject({ action: "passthrough", reason: "init dir/target mismatch" });
|
||||
expect(interceptor.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("fails fast instead of falling through after acknowledged chunks exceed the buffer cap", () => {
|
||||
const interceptor = new FastUploadInterceptor(1);
|
||||
const target = "/workspace/file.bin";
|
||||
|
||||
expect(
|
||||
interceptor.decide(
|
||||
`mkdir -p '/workspace' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`,
|
||||
),
|
||||
).toMatchObject({ action: "ack" });
|
||||
|
||||
const decision = interceptor.decide(`printf '%s' 'AAAA' >> '${target}.paperclip-upload.b64'`);
|
||||
expect(decision).toMatchObject({
|
||||
action: "error",
|
||||
message: expect.stringContaining("Fast upload buffer cap exceeded"),
|
||||
});
|
||||
expect(interceptor.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("fails fast when init repeats for an in-progress upload", () => {
|
||||
const interceptor = new FastUploadInterceptor();
|
||||
const target = "/workspace/file.bin";
|
||||
const initCommand =
|
||||
`mkdir -p '/workspace' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`;
|
||||
|
||||
expect(interceptor.decide(initCommand)).toMatchObject({ action: "ack" });
|
||||
expect(
|
||||
interceptor.decide(`printf '%s' 'aGVsbG8=' >> '${target}.paperclip-upload.b64'`),
|
||||
).toMatchObject({ action: "ack" });
|
||||
|
||||
const decision = interceptor.decide(initCommand);
|
||||
expect(decision).toMatchObject({
|
||||
action: "error",
|
||||
message: expect.stringContaining("Fast upload already in progress"),
|
||||
});
|
||||
expect(interceptor.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("clears buffered uploads on reset", () => {
|
||||
const interceptor = new FastUploadInterceptor();
|
||||
const target = "/workspace/file.bin";
|
||||
|
||||
interceptor.decide(
|
||||
`mkdir -p '/workspace' && rm -f '${target}.paperclip-upload.b64' && : > '${target}.paperclip-upload.b64'`,
|
||||
);
|
||||
expect(interceptor.pendingCount).toBe(1);
|
||||
|
||||
interceptor.reset();
|
||||
expect(interceptor.pendingCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { deriveCompanySlug, deriveNamespaceName, newRunUlidDns, paperclipLabels } from "../../src/utils.js";
|
||||
|
||||
describe("deriveCompanySlug", () => {
|
||||
it("lowercases and replaces non-alphanumerics", () => {
|
||||
expect(deriveCompanySlug("Acme Co!")).toBe("acme-co");
|
||||
});
|
||||
|
||||
it("truncates to 32 chars and strips trailing dashes", () => {
|
||||
expect(deriveCompanySlug("A".repeat(50))).toBe("a".repeat(32));
|
||||
expect(deriveCompanySlug("ab---")).toBe("ab");
|
||||
});
|
||||
|
||||
it("falls back to 'company' on empty/zero-letter input", () => {
|
||||
expect(deriveCompanySlug("!!!")).toBe("company");
|
||||
expect(deriveCompanySlug("")).toBe("company");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveNamespaceName", () => {
|
||||
it("concatenates prefix and slug", () => {
|
||||
expect(deriveNamespaceName("paperclip-", "acme-co")).toBe("paperclip-acme-co");
|
||||
});
|
||||
});
|
||||
|
||||
describe("newRunUlidDns", () => {
|
||||
it("produces a DNS-safe 26-char lowercase id", () => {
|
||||
const id = newRunUlidDns();
|
||||
expect(id).toMatch(/^[a-z0-9]{26}$/);
|
||||
});
|
||||
|
||||
it("does not use Math.random for the random suffix", () => {
|
||||
const spy = vi.spyOn(Math, "random");
|
||||
newRunUlidDns(() => 1);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("paperclipLabels", () => {
|
||||
it("returns canonical label map", () => {
|
||||
const labels = paperclipLabels({ runId: "r1", agentId: "a1", companyId: "c1", adapterType: "claude_local" });
|
||||
expect(labels["paperclip.io/run-id"]).toBe("r1");
|
||||
expect(labels["paperclip.io/agent-id"]).toBe("a1");
|
||||
expect(labels["paperclip.io/company-id"]).toBe("c1");
|
||||
expect(labels["paperclip.io/adapter"]).toBe("claude_local");
|
||||
expect(labels["paperclip.io/managed-by"]).toBe("paperclip-k8s-plugin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: [
|
||||
"test/unit/**/*.test.ts",
|
||||
...(process.env.RUN_K8S_INTEGRATION_TESTS === "1" ? ["test/integration/**/*.test.ts"] : []),
|
||||
],
|
||||
testTimeout: process.env.RUN_K8S_INTEGRATION_TESTS === "1" ? 120_000 : 5_000,
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -476,6 +476,13 @@ export interface PluginEnvironmentLease {
|
||||
|
||||
export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||||
runId: string;
|
||||
/**
|
||||
* UUID of the agent the run is being acquired for. Omitted only for ad-hoc
|
||||
* invocations (e.g. operator-initiated environment test probes) where no
|
||||
* agent context exists. Plugins should treat undefined as "no per-agent
|
||||
* partitioning available" and fall back to environment-level behavior.
|
||||
*/
|
||||
agentId?: string;
|
||||
workspaceMode?: string;
|
||||
requestedCwd?: string;
|
||||
}
|
||||
@@ -483,6 +490,14 @@ export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDr
|
||||
export interface PluginEnvironmentResumeLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||||
providerLeaseId: string;
|
||||
leaseMetadata?: Record<string, unknown>;
|
||||
/**
|
||||
* UUID of the agent the run is being resumed for. Symmetric with
|
||||
* `PluginEnvironmentAcquireLeaseParams.agentId`. Plugins can compare this
|
||||
* to the agentId they stored in `leaseMetadata` at acquire time; if it
|
||||
* doesn't match, return `{ providerLeaseId: null, metadata: { expired: true } }`
|
||||
* to force the host to create a fresh lease for the current agent.
|
||||
*/
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentReleaseLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||||
|
||||
@@ -281,6 +281,22 @@ export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumen
|
||||
export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const;
|
||||
export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number];
|
||||
|
||||
export const DOCUMENT_ANNOTATION_THREAD_STATUSES = ["open", "resolved"] as const;
|
||||
export type DocumentAnnotationThreadStatus = (typeof DOCUMENT_ANNOTATION_THREAD_STATUSES)[number];
|
||||
|
||||
export const DOCUMENT_ANNOTATION_ANCHOR_STATES = ["active", "stale", "orphaned"] as const;
|
||||
export type DocumentAnnotationAnchorState = (typeof DOCUMENT_ANNOTATION_ANCHOR_STATES)[number];
|
||||
|
||||
export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [
|
||||
"exact",
|
||||
"duplicate",
|
||||
"fuzzy",
|
||||
"ambiguous",
|
||||
"missing",
|
||||
] as const;
|
||||
export type DocumentAnnotationAnchorConfidence =
|
||||
(typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import type {
|
||||
DocumentAnnotationAnchorConfidence,
|
||||
DocumentAnnotationAnchorState,
|
||||
} from "./constants.js";
|
||||
import type {
|
||||
DocumentAnnotationAnchorSelector,
|
||||
DocumentAnnotationAnchorSnapshot,
|
||||
DocumentTextPosition,
|
||||
DocumentTextProjection,
|
||||
DocumentTextRange,
|
||||
} from "./types/document-annotation.js";
|
||||
|
||||
export interface CreateDocumentAnchorSelectorOptions {
|
||||
contextLength?: number;
|
||||
}
|
||||
|
||||
export interface VerifyDocumentAnchorSelectorInput {
|
||||
markdown: string;
|
||||
selector: DocumentAnnotationAnchorSelector;
|
||||
contextLength?: number;
|
||||
}
|
||||
|
||||
export interface VerifyDocumentAnchorSelectorResult {
|
||||
ok: boolean;
|
||||
anchor: DocumentAnnotationAnchorSnapshot | null;
|
||||
projection: DocumentTextProjection;
|
||||
reason: "verified" | "quote_mismatch" | "position_mismatch" | "invalid_range";
|
||||
}
|
||||
|
||||
export interface RemapDocumentAnchorInput {
|
||||
previousAnchor: DocumentAnnotationAnchorSnapshot;
|
||||
nextMarkdown: string;
|
||||
contextLength?: number;
|
||||
}
|
||||
|
||||
export interface RemapDocumentAnchorResult {
|
||||
anchorState: DocumentAnnotationAnchorState;
|
||||
confidence: DocumentAnnotationAnchorConfidence;
|
||||
anchor: DocumentAnnotationAnchorSnapshot | null;
|
||||
projection: DocumentTextProjection;
|
||||
reason: "exact" | "duplicate" | "fuzzy" | "ambiguous" | "missing";
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
reason: RemapDocumentAnchorResult["reason"];
|
||||
}
|
||||
|
||||
const DEFAULT_CONTEXT_LENGTH = 48;
|
||||
|
||||
export function normalizeAnchorText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function projectMarkdownToText(markdown: string): DocumentTextProjection {
|
||||
const builder = new ProjectionBuilder(markdown);
|
||||
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? [markdown];
|
||||
let offset = 0;
|
||||
let inFence = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
if (rawLine === "") continue;
|
||||
const hasNewline = rawLine.endsWith("\n");
|
||||
const line = hasNewline ? rawLine.slice(0, -1) : rawLine;
|
||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||
|
||||
if (fenceMatch) {
|
||||
inFence = !inFence;
|
||||
offset += rawLine.length;
|
||||
builder.addSeparator(offset - (hasNewline ? 1 : 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
builder.addText(line, offset);
|
||||
builder.addSeparator(offset + line.length);
|
||||
offset += rawLine.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { text, sourceOffset } = stripBlockSyntax(line, offset);
|
||||
addInlineMarkdownText(builder, text, sourceOffset);
|
||||
builder.addSeparator(offset + line.length);
|
||||
offset += rawLine.length;
|
||||
}
|
||||
|
||||
return builder.toProjection();
|
||||
}
|
||||
|
||||
export function resolveProjectionRange(
|
||||
projection: DocumentTextProjection,
|
||||
normalizedStart: number,
|
||||
normalizedEnd: number,
|
||||
): DocumentTextRange | null {
|
||||
if (
|
||||
normalizedStart < 0
|
||||
|| normalizedEnd <= normalizedStart
|
||||
|| normalizedEnd > projection.text.length
|
||||
|| normalizedStart >= projection.positions.length
|
||||
|| normalizedEnd - 1 >= projection.positions.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text: projection.text.slice(normalizedStart, normalizedEnd),
|
||||
normalizedStart,
|
||||
normalizedEnd,
|
||||
markdownStart: projection.positions[normalizedStart]?.sourceStart ?? 0,
|
||||
markdownEnd: projection.positions[normalizedEnd - 1]?.sourceEnd ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDocumentAnchorSelector(
|
||||
projection: DocumentTextProjection,
|
||||
range: DocumentTextRange,
|
||||
options: CreateDocumentAnchorSelectorOptions = {},
|
||||
): DocumentAnnotationAnchorSelector {
|
||||
const contextLength = options.contextLength ?? DEFAULT_CONTEXT_LENGTH;
|
||||
return {
|
||||
quote: {
|
||||
exact: range.text,
|
||||
prefix: projection.text.slice(Math.max(0, range.normalizedStart - contextLength), range.normalizedStart),
|
||||
suffix: projection.text.slice(range.normalizedEnd, range.normalizedEnd + contextLength),
|
||||
},
|
||||
position: {
|
||||
normalizedStart: range.normalizedStart,
|
||||
normalizedEnd: range.normalizedEnd,
|
||||
markdownStart: range.markdownStart,
|
||||
markdownEnd: range.markdownEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function selectorToAnchorSnapshot(selector: DocumentAnnotationAnchorSelector): DocumentAnnotationAnchorSnapshot {
|
||||
return {
|
||||
selectedText: selector.quote.exact,
|
||||
prefixText: selector.quote.prefix,
|
||||
suffixText: selector.quote.suffix,
|
||||
normalizedStart: selector.position.normalizedStart,
|
||||
normalizedEnd: selector.position.normalizedEnd,
|
||||
markdownStart: selector.position.markdownStart,
|
||||
markdownEnd: selector.position.markdownEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export function anchorSnapshotToSelector(anchor: DocumentAnnotationAnchorSnapshot): DocumentAnnotationAnchorSelector {
|
||||
return {
|
||||
quote: {
|
||||
exact: anchor.selectedText,
|
||||
prefix: anchor.prefixText,
|
||||
suffix: anchor.suffixText,
|
||||
},
|
||||
position: {
|
||||
normalizedStart: anchor.normalizedStart,
|
||||
normalizedEnd: anchor.normalizedEnd,
|
||||
markdownStart: anchor.markdownStart,
|
||||
markdownEnd: anchor.markdownEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyDocumentAnchorSelector(
|
||||
input: VerifyDocumentAnchorSelectorInput,
|
||||
): VerifyDocumentAnchorSelectorResult {
|
||||
const projection = projectMarkdownToText(input.markdown);
|
||||
const range = resolveProjectionRange(
|
||||
projection,
|
||||
input.selector.position.normalizedStart,
|
||||
input.selector.position.normalizedEnd,
|
||||
);
|
||||
if (!range) {
|
||||
return { ok: false, anchor: null, projection, reason: "invalid_range" };
|
||||
}
|
||||
|
||||
if (normalizeAnchorText(range.text) !== normalizeAnchorText(input.selector.quote.exact)) {
|
||||
return { ok: false, anchor: null, projection, reason: "quote_mismatch" };
|
||||
}
|
||||
|
||||
if (
|
||||
range.markdownStart !== input.selector.position.markdownStart
|
||||
|| range.markdownEnd !== input.selector.position.markdownEnd
|
||||
) {
|
||||
return { ok: false, anchor: null, projection, reason: "position_mismatch" };
|
||||
}
|
||||
|
||||
const selector = createDocumentAnchorSelector(projection, range, {
|
||||
contextLength: input.contextLength ?? DEFAULT_CONTEXT_LENGTH,
|
||||
});
|
||||
return { ok: true, anchor: selectorToAnchorSnapshot(selector), projection, reason: "verified" };
|
||||
}
|
||||
|
||||
export function remapDocumentAnchor(input: RemapDocumentAnchorInput): RemapDocumentAnchorResult {
|
||||
const projection = projectMarkdownToText(input.nextMarkdown);
|
||||
const contextLength = input.contextLength ?? DEFAULT_CONTEXT_LENGTH;
|
||||
const quote = normalizeAnchorText(input.previousAnchor.selectedText);
|
||||
if (!quote) {
|
||||
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
|
||||
}
|
||||
|
||||
const exactCandidates = findOccurrences(projection.text, quote).map((start) => scoreCandidate({
|
||||
projection,
|
||||
start,
|
||||
end: start + quote.length,
|
||||
previousAnchor: input.previousAnchor,
|
||||
reason: "exact",
|
||||
contextLength,
|
||||
}));
|
||||
|
||||
if (exactCandidates.length > 0) {
|
||||
exactCandidates.sort((a, b) => b.score - a.score);
|
||||
const [best, second] = exactCandidates;
|
||||
if (exactCandidates.length > 1 && (!second || Math.abs(best.score - second.score) < 0.05)) {
|
||||
return {
|
||||
anchorState: "stale",
|
||||
confidence: "ambiguous",
|
||||
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
|
||||
projection,
|
||||
reason: "ambiguous",
|
||||
};
|
||||
}
|
||||
return {
|
||||
anchorState: "active",
|
||||
confidence: exactCandidates.length === 1 ? "exact" : "duplicate",
|
||||
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
|
||||
projection,
|
||||
reason: exactCandidates.length === 1 ? "exact" : "duplicate",
|
||||
};
|
||||
}
|
||||
|
||||
const fuzzy = findFuzzyCandidate(projection, input.previousAnchor, contextLength);
|
||||
if (fuzzy && fuzzy.score >= 0.58) {
|
||||
return {
|
||||
anchorState: "stale",
|
||||
confidence: "fuzzy",
|
||||
anchor: buildAnchorSnapshot(projection, fuzzy.start, fuzzy.end, contextLength),
|
||||
projection,
|
||||
reason: "fuzzy",
|
||||
};
|
||||
}
|
||||
|
||||
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
|
||||
}
|
||||
|
||||
function stripBlockSyntax(line: string, absoluteOffset: number): { text: string; sourceOffset: number } {
|
||||
const blockMatch = line.match(/^\s{0,3}(?:(#{1,6})\s+|(?:[-+*]|\d+[.)])\s+|>\s?)/);
|
||||
if (!blockMatch) return { text: line, sourceOffset: absoluteOffset };
|
||||
return { text: line.slice(blockMatch[0].length), sourceOffset: absoluteOffset + blockMatch[0].length };
|
||||
}
|
||||
|
||||
function addInlineMarkdownText(builder: ProjectionBuilder, text: string, sourceOffset: number): void {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index] ?? "";
|
||||
const absolute = sourceOffset + index;
|
||||
const rest = text.slice(index);
|
||||
|
||||
const image = rest.match(/^!\[([^\]]*)\]\(([^)]*)\)/);
|
||||
if (image) {
|
||||
const altStart = absolute + 2;
|
||||
builder.addText(image[1] ?? "", altStart);
|
||||
index += image[0].length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const link = rest.match(/^\[([^\]]+)\]\(([^)]*)\)/);
|
||||
if (link) {
|
||||
const labelStart = absolute + 1;
|
||||
builder.addText(link[1] ?? "", labelStart);
|
||||
index += link[0].length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "`") {
|
||||
const closing = text.indexOf("`", index + 1);
|
||||
if (closing > index + 1) {
|
||||
builder.addText(text.slice(index + 1, closing), absolute + 1);
|
||||
index = closing;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (char === "|" || char === "\t") {
|
||||
builder.addSeparator(absolute);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMarkdownFormattingChar(char, text, index)) continue;
|
||||
|
||||
builder.addChar(char, absolute, absolute + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function isMarkdownFormattingChar(char: string, text: string, index: number): boolean {
|
||||
if (char === "*" || char === "_" || char === "~") return true;
|
||||
if (char === "\\" && index + 1 < text.length) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function findOccurrences(text: string, quote: string): number[] {
|
||||
const starts: number[] = [];
|
||||
let start = text.indexOf(quote);
|
||||
while (start !== -1) {
|
||||
starts.push(start);
|
||||
start = text.indexOf(quote, start + 1);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function scoreCandidate(args: {
|
||||
projection: DocumentTextProjection;
|
||||
start: number;
|
||||
end: number;
|
||||
previousAnchor: DocumentAnnotationAnchorSnapshot;
|
||||
reason: Candidate["reason"];
|
||||
contextLength: number;
|
||||
}): Candidate {
|
||||
const before = args.projection.text.slice(Math.max(0, args.start - args.contextLength), args.start);
|
||||
const after = args.projection.text.slice(args.end, args.end + args.contextLength);
|
||||
const prefixScore = suffixOverlapScore(args.previousAnchor.prefixText, before);
|
||||
const suffixScore = prefixOverlapScore(args.previousAnchor.suffixText, after);
|
||||
const distance = Math.abs(args.start - args.previousAnchor.normalizedStart);
|
||||
const proximity = 1 / (1 + distance / 200);
|
||||
return {
|
||||
start: args.start,
|
||||
end: args.end,
|
||||
score: prefixScore * 0.35 + suffixScore * 0.35 + proximity * 0.3,
|
||||
reason: args.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function findFuzzyCandidate(
|
||||
projection: DocumentTextProjection,
|
||||
previousAnchor: DocumentAnnotationAnchorSnapshot,
|
||||
contextLength: number,
|
||||
): Candidate | null {
|
||||
const words = normalizeAnchorText(previousAnchor.selectedText).split(" ").filter(Boolean);
|
||||
if (words.length === 0) return null;
|
||||
const textWords = [...projection.text.matchAll(/\S+/g)].map((match) => ({
|
||||
text: match[0],
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
}));
|
||||
const windowSizes = new Set([words.length - 1, words.length, words.length + 1, words.length + 2].filter((n) => n > 0));
|
||||
let best: Candidate | null = null;
|
||||
|
||||
for (const size of windowSizes) {
|
||||
for (let index = 0; index + size <= textWords.length; index += 1) {
|
||||
const window = textWords.slice(index, index + size);
|
||||
const candidateText = window.map((word) => word.text).join(" ");
|
||||
const similarity = similarityScore(normalizeAnchorText(previousAnchor.selectedText), candidateText);
|
||||
if (similarity < 0.45) continue;
|
||||
const scored = scoreCandidate({
|
||||
projection,
|
||||
start: window[0]?.start ?? 0,
|
||||
end: window[window.length - 1]?.end ?? 0,
|
||||
previousAnchor,
|
||||
reason: "fuzzy",
|
||||
contextLength,
|
||||
});
|
||||
scored.score = scored.score * 0.35 + similarity * 0.65;
|
||||
if (!best || scored.score > best.score) best = scored;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function buildAnchorSnapshot(
|
||||
projection: DocumentTextProjection,
|
||||
normalizedStart: number,
|
||||
normalizedEnd: number,
|
||||
contextLength: number,
|
||||
): DocumentAnnotationAnchorSnapshot {
|
||||
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
|
||||
if (!range) {
|
||||
return {
|
||||
selectedText: "",
|
||||
prefixText: "",
|
||||
suffixText: "",
|
||||
normalizedStart,
|
||||
normalizedEnd,
|
||||
markdownStart: 0,
|
||||
markdownEnd: 0,
|
||||
};
|
||||
}
|
||||
const selector = createDocumentAnchorSelector(projection, range, { contextLength });
|
||||
return selectorToAnchorSnapshot(selector);
|
||||
}
|
||||
|
||||
function prefixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
|
||||
const expected = normalizeAnchorText(expectedPrefix);
|
||||
const actual = normalizeAnchorText(actualPrefix);
|
||||
if (!expected) return 0.5;
|
||||
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
|
||||
if (expected.slice(0, size) === actual.slice(0, size)) return size / expected.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function suffixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
|
||||
const expected = normalizeAnchorText(expectedPrefix);
|
||||
const actual = normalizeAnchorText(actualPrefix);
|
||||
if (!expected) return 0.5;
|
||||
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
|
||||
if (expected.slice(-size) === actual.slice(-size)) return size / expected.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function similarityScore(left: string, right: string): number {
|
||||
if (left === right) return 1;
|
||||
const leftWords = new Set(left.toLowerCase().split(/\s+/).filter(Boolean));
|
||||
const rightWords = new Set(right.toLowerCase().split(/\s+/).filter(Boolean));
|
||||
const intersection = [...leftWords].filter((word) => rightWords.has(word)).length;
|
||||
const union = new Set([...leftWords, ...rightWords]).size || 1;
|
||||
const jaccard = intersection / union;
|
||||
const lengthRatio = Math.min(left.length, right.length) / Math.max(left.length, right.length, 1);
|
||||
return jaccard * 0.75 + lengthRatio * 0.25;
|
||||
}
|
||||
|
||||
class ProjectionBuilder {
|
||||
private text = "";
|
||||
private positions: DocumentTextPosition[] = [];
|
||||
private pendingSpace: DocumentTextPosition | null = null;
|
||||
|
||||
constructor(private readonly source: string) {}
|
||||
|
||||
addText(text: string, sourceOffset: number): void {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
this.addChar(text[index] ?? "", sourceOffset + index, sourceOffset + index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
addSeparator(sourceOffset: number): void {
|
||||
this.addChar(" ", sourceOffset, sourceOffset + 1);
|
||||
}
|
||||
|
||||
addChar(char: string, sourceStart: number, sourceEnd: number): void {
|
||||
if (/\s/.test(char)) {
|
||||
if (this.text.length > 0 && !this.pendingSpace) {
|
||||
this.pendingSpace = { sourceStart, sourceEnd };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingSpace && this.text.length > 0) {
|
||||
this.text += " ";
|
||||
this.positions.push(this.pendingSpace);
|
||||
}
|
||||
this.pendingSpace = null;
|
||||
this.text += char;
|
||||
this.positions.push({ sourceStart, sourceEnd });
|
||||
}
|
||||
|
||||
toProjection(): DocumentTextProjection {
|
||||
return {
|
||||
source: this.source,
|
||||
text: this.text,
|
||||
positions: this.positions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ export {
|
||||
SYSTEM_ISSUE_DOCUMENT_KEYS,
|
||||
isSystemIssueDocumentKey,
|
||||
ISSUE_REFERENCE_SOURCE_KINDS,
|
||||
DOCUMENT_ANNOTATION_THREAD_STATUSES,
|
||||
DOCUMENT_ANNOTATION_ANCHOR_STATES,
|
||||
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_MONITOR_SCHEDULED_BY,
|
||||
@@ -164,6 +167,9 @@ export {
|
||||
type IssueTreeHoldStatus,
|
||||
type SystemIssueDocumentKey,
|
||||
type IssueReferenceSourceKind,
|
||||
type DocumentAnnotationThreadStatus,
|
||||
type DocumentAnnotationAnchorState,
|
||||
type DocumentAnnotationAnchorConfidence,
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueMonitorScheduledBy,
|
||||
@@ -290,6 +296,13 @@ export type {
|
||||
CompanySkillUsageAgent,
|
||||
CompanySkillDetail,
|
||||
CompanySkillUpdateStatus,
|
||||
CompanySkillAuditSeverity,
|
||||
CompanySkillAuditVerdict,
|
||||
CompanySkillUpdateHoldReason,
|
||||
CompanySkillAuditFinding,
|
||||
CompanySkillAuditResult,
|
||||
CompanySkillInstallUpdateRequest,
|
||||
CompanySkillResetRequest,
|
||||
CompanySkillImportRequest,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillProjectScanRequest,
|
||||
@@ -299,6 +312,14 @@ export type {
|
||||
CompanySkillCreateRequest,
|
||||
CompanySkillFileDetail,
|
||||
CompanySkillFileUpdateRequest,
|
||||
CatalogSkillKind,
|
||||
CatalogSkillFileKind,
|
||||
CatalogSkillFile,
|
||||
CatalogSkill,
|
||||
CatalogSkillListQuery,
|
||||
CatalogSkillFileDetail,
|
||||
CompanySkillInstallCatalogRequest,
|
||||
CompanySkillInstallCatalogResult,
|
||||
AgentSkillSyncMode,
|
||||
AgentSkillState,
|
||||
AgentSkillOrigin,
|
||||
@@ -376,6 +397,20 @@ export type {
|
||||
IssueWorkProductProvider,
|
||||
IssueWorkProductStatus,
|
||||
IssueWorkProductReviewState,
|
||||
CreateDocumentAnnotationCommentRequest,
|
||||
CreateDocumentAnnotationThreadRequest,
|
||||
DocumentAnnotationAnchorRemapSnapshot,
|
||||
DocumentAnnotationAnchorSelector,
|
||||
DocumentAnnotationAnchorSnapshot,
|
||||
DocumentAnnotationComment,
|
||||
DocumentAnnotationTextPositionSelector,
|
||||
DocumentAnnotationTextQuoteSelector,
|
||||
DocumentAnnotationThread,
|
||||
DocumentAnnotationThreadWithComments,
|
||||
DocumentTextPosition,
|
||||
DocumentTextProjection,
|
||||
DocumentTextRange,
|
||||
UpdateDocumentAnnotationThreadRequest,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerAttention,
|
||||
@@ -654,6 +689,22 @@ export {
|
||||
type IssueReferenceMatch,
|
||||
} from "./issue-references.js";
|
||||
|
||||
export {
|
||||
anchorSnapshotToSelector,
|
||||
createDocumentAnchorSelector,
|
||||
normalizeAnchorText,
|
||||
projectMarkdownToText,
|
||||
remapDocumentAnchor,
|
||||
resolveProjectionRange,
|
||||
selectorToAnchorSnapshot,
|
||||
verifyDocumentAnchorSelector,
|
||||
type CreateDocumentAnchorSelectorOptions,
|
||||
type RemapDocumentAnchorInput,
|
||||
type RemapDocumentAnchorResult,
|
||||
type VerifyDocumentAnchorSelectorInput,
|
||||
type VerifyDocumentAnchorSelectorResult,
|
||||
} from "./document-anchors.js";
|
||||
|
||||
export {
|
||||
sidebarOrderPreferenceSchema,
|
||||
upsertSidebarOrderPreferenceSchema,
|
||||
@@ -795,6 +846,18 @@ export {
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
createDocumentAnnotationCommentSchema,
|
||||
createDocumentAnnotationThreadSchema,
|
||||
documentAnnotationAnchorConfidenceSchema,
|
||||
documentAnnotationAnchorSelectorSchema,
|
||||
documentAnnotationAnchorStateSchema,
|
||||
documentAnnotationTextPositionSelectorSchema,
|
||||
documentAnnotationTextQuoteSelectorSchema,
|
||||
documentAnnotationThreadStatusSchema,
|
||||
updateDocumentAnnotationThreadSchema,
|
||||
type CreateDocumentAnnotationComment,
|
||||
type CreateDocumentAnnotationThread,
|
||||
type UpdateDocumentAnnotationThread,
|
||||
companySearchQuerySchema,
|
||||
COMPANY_SEARCH_DEFAULT_LIMIT,
|
||||
COMPANY_SEARCH_MAX_LIMIT,
|
||||
@@ -1012,6 +1075,8 @@ export {
|
||||
companySkillUsageAgentSchema,
|
||||
companySkillDetailSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
companySkillAuditFindingSchema,
|
||||
companySkillAuditResultSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillProjectScanSkippedSchema,
|
||||
@@ -1020,6 +1085,15 @@ export {
|
||||
companySkillCreateSchema,
|
||||
companySkillFileDetailSchema,
|
||||
companySkillFileUpdateSchema,
|
||||
catalogSkillKindSchema,
|
||||
catalogSkillFileSchema,
|
||||
catalogSkillSchema,
|
||||
catalogSkillListQuerySchema,
|
||||
catalogSkillFileDetailSchema,
|
||||
companySkillInstallCatalogSchema,
|
||||
companySkillInstallCatalogResultSchema,
|
||||
companySkillInstallUpdateSchema,
|
||||
companySkillResetSchema,
|
||||
portabilityIncludeSchema,
|
||||
portabilityEnvInputSchema,
|
||||
portabilityCompanyManifestEntrySchema,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { AgentEnvConfig, SecretProvider } from "./secrets.js";
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
import type { IssueCommentAuthorType } from "../constants.js";
|
||||
import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js";
|
||||
@@ -20,6 +20,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 =
|
||||
@@ -179,6 +183,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 {
|
||||
@@ -330,4 +343,5 @@ export interface CompanyPortabilityExportRequest {
|
||||
selectedFiles?: string[];
|
||||
expandReferencedSkills?: boolean;
|
||||
sidebarOrder?: Partial<CompanyPortabilitySidebarOrder>;
|
||||
includeSecrets?: boolean;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface CompanySkillListItem {
|
||||
sourceLabel: string | null;
|
||||
sourceBadge: CompanySkillSourceBadge;
|
||||
sourcePath: string | null;
|
||||
catalogKind: "bundled" | "optional" | null;
|
||||
originHash: string | null;
|
||||
packageName: string | null;
|
||||
packageVersion: string | null;
|
||||
}
|
||||
|
||||
export interface CompanySkillUsageAgent {
|
||||
@@ -84,6 +88,49 @@ export interface CompanySkillUpdateStatus {
|
||||
currentRef: string | null;
|
||||
latestRef: string | null;
|
||||
hasUpdate: boolean;
|
||||
installedHash: string | null;
|
||||
originHash: string | null;
|
||||
userModifiedAt: string | null;
|
||||
updateHoldReason: CompanySkillUpdateHoldReason | null;
|
||||
auditVerdict: CompanySkillAuditVerdict | null;
|
||||
auditCodes: string[];
|
||||
}
|
||||
|
||||
export type CompanySkillAuditSeverity = "warning" | "error";
|
||||
|
||||
export type CompanySkillAuditVerdict = "pass" | "warning" | "fail";
|
||||
|
||||
export type CompanySkillUpdateHoldReason =
|
||||
| "local_modifications"
|
||||
| "audit_hard_stop"
|
||||
| "origin_unavailable"
|
||||
| "compatibility_invalid"
|
||||
| "operator_hold";
|
||||
|
||||
export interface CompanySkillAuditFinding {
|
||||
code: string;
|
||||
severity: CompanySkillAuditSeverity;
|
||||
message: string;
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export interface CompanySkillAuditResult {
|
||||
skillId: string;
|
||||
installedHash: string | null;
|
||||
originHash: string | null;
|
||||
verdict: CompanySkillAuditVerdict;
|
||||
codes: string[];
|
||||
findings: CompanySkillAuditFinding[];
|
||||
scannedAt: string;
|
||||
scanVersion: string;
|
||||
}
|
||||
|
||||
export interface CompanySkillInstallUpdateRequest {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySkillResetRequest {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySkillImportRequest {
|
||||
@@ -155,3 +202,64 @@ export interface CompanySkillFileUpdateRequest {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type CatalogSkillKind = "bundled" | "optional";
|
||||
|
||||
export type CatalogSkillFileKind = CompanySkillFileInventoryEntry["kind"];
|
||||
|
||||
export interface CatalogSkillFile {
|
||||
path: string;
|
||||
kind: CatalogSkillFileKind;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface CatalogSkill {
|
||||
id: string;
|
||||
key: string;
|
||||
kind: CatalogSkillKind;
|
||||
category: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
entrypoint: "SKILL.md";
|
||||
trustLevel: CompanySkillTrustLevel;
|
||||
compatibility: CompanySkillCompatibility;
|
||||
defaultInstall: boolean;
|
||||
recommendedForRoles: string[];
|
||||
requires: string[];
|
||||
tags: string[];
|
||||
files: CatalogSkillFile[];
|
||||
contentHash: string;
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
}
|
||||
|
||||
export interface CatalogSkillListQuery {
|
||||
kind?: CatalogSkillKind;
|
||||
category?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface CatalogSkillFileDetail {
|
||||
catalogSkillId: string;
|
||||
path: string;
|
||||
kind: CatalogSkillFileKind;
|
||||
content: string;
|
||||
language: string | null;
|
||||
markdown: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySkillInstallCatalogRequest {
|
||||
catalogSkillId: string;
|
||||
slug?: string | null;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySkillInstallCatalogResult {
|
||||
action: "created" | "updated" | "unchanged";
|
||||
skill: CompanySkill;
|
||||
catalogSkill: CatalogSkill;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
DocumentAnnotationAnchorConfidence,
|
||||
DocumentAnnotationAnchorState,
|
||||
DocumentAnnotationThreadStatus,
|
||||
IssueCommentAuthorType,
|
||||
} from "../constants.js";
|
||||
|
||||
export interface DocumentTextPosition {
|
||||
sourceStart: number;
|
||||
sourceEnd: number;
|
||||
}
|
||||
|
||||
export interface DocumentTextProjection {
|
||||
source: string;
|
||||
text: string;
|
||||
positions: DocumentTextPosition[];
|
||||
}
|
||||
|
||||
export interface DocumentTextRange {
|
||||
text: string;
|
||||
normalizedStart: number;
|
||||
normalizedEnd: number;
|
||||
markdownStart: number;
|
||||
markdownEnd: number;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationTextQuoteSelector {
|
||||
exact: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationTextPositionSelector {
|
||||
normalizedStart: number;
|
||||
normalizedEnd: number;
|
||||
markdownStart: number;
|
||||
markdownEnd: number;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationAnchorSelector {
|
||||
quote: DocumentAnnotationTextQuoteSelector;
|
||||
position: DocumentAnnotationTextPositionSelector;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationAnchorSnapshot {
|
||||
selectedText: string;
|
||||
prefixText: string;
|
||||
suffixText: string;
|
||||
normalizedStart: number;
|
||||
normalizedEnd: number;
|
||||
markdownStart: number;
|
||||
markdownEnd: number;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationThread {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
documentId: string;
|
||||
documentKey: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
anchorState: DocumentAnnotationAnchorState;
|
||||
anchorConfidence: DocumentAnnotationAnchorConfidence;
|
||||
originalRevisionId: string | null;
|
||||
originalRevisionNumber: number;
|
||||
currentRevisionId: string | null;
|
||||
currentRevisionNumber: number;
|
||||
selectedText: string;
|
||||
prefixText: string;
|
||||
suffixText: string;
|
||||
normalizedStart: number;
|
||||
normalizedEnd: number;
|
||||
markdownStart: number;
|
||||
markdownEnd: number;
|
||||
anchorSelector: DocumentAnnotationAnchorSelector;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
resolvedByAgentId: string | null;
|
||||
resolvedByUserId: string | null;
|
||||
resolvedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationComment {
|
||||
id: string;
|
||||
companyId: string;
|
||||
threadId: string;
|
||||
issueId: string;
|
||||
documentId: string;
|
||||
body: string;
|
||||
authorType: IssueCommentAuthorType;
|
||||
authorAgentId: string | null;
|
||||
authorUserId: string | null;
|
||||
createdByRunId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationAnchorRemapSnapshot {
|
||||
id: string;
|
||||
companyId: string;
|
||||
threadId: string;
|
||||
documentId: string;
|
||||
fromRevisionId: string | null;
|
||||
fromRevisionNumber: number | null;
|
||||
toRevisionId: string | null;
|
||||
toRevisionNumber: number;
|
||||
previousAnchor: DocumentAnnotationAnchorSnapshot;
|
||||
nextAnchor: DocumentAnnotationAnchorSnapshot | null;
|
||||
anchorState: DocumentAnnotationAnchorState;
|
||||
anchorConfidence: DocumentAnnotationAnchorConfidence;
|
||||
failureReason: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationThreadWithComments extends DocumentAnnotationThread {
|
||||
comments: DocumentAnnotationComment[];
|
||||
}
|
||||
|
||||
export interface CreateDocumentAnnotationThreadRequest {
|
||||
baseRevisionId: string;
|
||||
baseRevisionNumber: number;
|
||||
selector: DocumentAnnotationAnchorSelector;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface CreateDocumentAnnotationCommentRequest {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentAnnotationThreadRequest {
|
||||
status?: DocumentAnnotationThreadStatus;
|
||||
}
|
||||
@@ -51,6 +51,13 @@ export type {
|
||||
CompanySkillUsageAgent,
|
||||
CompanySkillDetail,
|
||||
CompanySkillUpdateStatus,
|
||||
CompanySkillAuditSeverity,
|
||||
CompanySkillAuditVerdict,
|
||||
CompanySkillUpdateHoldReason,
|
||||
CompanySkillAuditFinding,
|
||||
CompanySkillAuditResult,
|
||||
CompanySkillInstallUpdateRequest,
|
||||
CompanySkillResetRequest,
|
||||
CompanySkillImportRequest,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillProjectScanRequest,
|
||||
@@ -60,6 +67,14 @@ export type {
|
||||
CompanySkillCreateRequest,
|
||||
CompanySkillFileDetail,
|
||||
CompanySkillFileUpdateRequest,
|
||||
CatalogSkillKind,
|
||||
CatalogSkillFileKind,
|
||||
CatalogSkillFile,
|
||||
CatalogSkill,
|
||||
CatalogSkillListQuery,
|
||||
CatalogSkillFileDetail,
|
||||
CompanySkillInstallCatalogRequest,
|
||||
CompanySkillInstallCatalogResult,
|
||||
} from "./company-skill.js";
|
||||
export type {
|
||||
AgentSkillSyncMode,
|
||||
@@ -89,6 +104,22 @@ export type {
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "./agent.js";
|
||||
export type { AssetImage } from "./asset.js";
|
||||
export type {
|
||||
CreateDocumentAnnotationCommentRequest,
|
||||
CreateDocumentAnnotationThreadRequest,
|
||||
DocumentAnnotationAnchorRemapSnapshot,
|
||||
DocumentAnnotationAnchorSelector,
|
||||
DocumentAnnotationAnchorSnapshot,
|
||||
DocumentAnnotationComment,
|
||||
DocumentAnnotationTextPositionSelector,
|
||||
DocumentAnnotationTextQuoteSelector,
|
||||
DocumentAnnotationThread,
|
||||
DocumentAnnotationThreadWithComments,
|
||||
DocumentTextPosition,
|
||||
DocumentTextProjection,
|
||||
DocumentTextRange,
|
||||
UpdateDocumentAnnotationThreadRequest,
|
||||
} from "./document-annotation.js";
|
||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
CompanySearchHighlight,
|
||||
|
||||
@@ -26,6 +26,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([
|
||||
@@ -191,6 +194,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", [
|
||||
@@ -233,6 +243,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>;
|
||||
|
||||
@@ -35,6 +35,10 @@ export const companySkillListItemSchema = companySkillSchema.extend({
|
||||
editableReason: z.string().nullable(),
|
||||
sourceLabel: z.string().nullable(),
|
||||
sourceBadge: companySkillSourceBadgeSchema,
|
||||
catalogKind: z.enum(["bundled", "optional"]).nullable(),
|
||||
originHash: z.string().nullable(),
|
||||
packageName: z.string().nullable(),
|
||||
packageVersion: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const companySkillUsageAgentSchema = z.object({
|
||||
@@ -64,8 +68,46 @@ export const companySkillUpdateStatusSchema = z.object({
|
||||
currentRef: z.string().nullable(),
|
||||
latestRef: z.string().nullable(),
|
||||
hasUpdate: z.boolean(),
|
||||
installedHash: z.string().nullable(),
|
||||
originHash: z.string().nullable(),
|
||||
userModifiedAt: z.string().nullable(),
|
||||
updateHoldReason: z.enum([
|
||||
"local_modifications",
|
||||
"audit_hard_stop",
|
||||
"origin_unavailable",
|
||||
"compatibility_invalid",
|
||||
"operator_hold",
|
||||
]).nullable(),
|
||||
auditVerdict: z.enum(["pass", "warning", "fail"]).nullable(),
|
||||
auditCodes: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const companySkillAuditFindingSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
severity: z.enum(["warning", "error"]),
|
||||
message: z.string().min(1),
|
||||
path: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const companySkillAuditResultSchema = z.object({
|
||||
skillId: z.string().uuid(),
|
||||
installedHash: z.string().nullable(),
|
||||
originHash: z.string().nullable(),
|
||||
verdict: z.enum(["pass", "warning", "fail"]),
|
||||
codes: z.array(z.string()),
|
||||
findings: z.array(companySkillAuditFindingSchema),
|
||||
scannedAt: z.string().min(1),
|
||||
scanVersion: z.string().min(1),
|
||||
});
|
||||
|
||||
export const companySkillInstallUpdateSchema = z.object({
|
||||
force: z.boolean().optional(),
|
||||
}).default({});
|
||||
|
||||
export const companySkillResetSchema = z.object({
|
||||
force: z.boolean().optional(),
|
||||
}).default({});
|
||||
|
||||
export const companySkillImportSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
});
|
||||
@@ -131,7 +173,70 @@ export const companySkillFileUpdateSchema = z.object({
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const catalogSkillKindSchema = z.enum(["bundled", "optional"]);
|
||||
|
||||
export const catalogSkillFileSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
|
||||
sizeBytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().min(1),
|
||||
});
|
||||
|
||||
export const catalogSkillSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
key: z.string().min(1),
|
||||
kind: catalogSkillKindSchema,
|
||||
category: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
path: z.string().min(1),
|
||||
entrypoint: z.literal("SKILL.md"),
|
||||
trustLevel: companySkillTrustLevelSchema,
|
||||
compatibility: companySkillCompatibilitySchema,
|
||||
defaultInstall: z.boolean(),
|
||||
recommendedForRoles: z.array(z.string()),
|
||||
requires: z.array(z.string()),
|
||||
tags: z.array(z.string()),
|
||||
files: z.array(catalogSkillFileSchema),
|
||||
contentHash: z.string().min(1),
|
||||
packageName: z.string().min(1).optional(),
|
||||
packageVersion: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const catalogSkillListQuerySchema = z.object({
|
||||
kind: catalogSkillKindSchema.optional(),
|
||||
category: z.string().min(1).optional(),
|
||||
q: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const catalogSkillFileDetailSchema = z.object({
|
||||
catalogSkillId: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
|
||||
content: z.string(),
|
||||
language: z.string().nullable(),
|
||||
markdown: z.boolean(),
|
||||
});
|
||||
|
||||
export const companySkillInstallCatalogSchema = z.object({
|
||||
catalogSkillId: z.string().min(1),
|
||||
slug: z.string().min(1).nullable().optional(),
|
||||
force: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const companySkillInstallCatalogResultSchema = z.object({
|
||||
action: z.enum(["created", "updated", "unchanged"]),
|
||||
skill: companySkillSchema,
|
||||
catalogSkill: catalogSkillSchema,
|
||||
warnings: z.array(z.string()),
|
||||
});
|
||||
|
||||
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 CatalogSkillListQuery = z.infer<typeof catalogSkillListQuerySchema>;
|
||||
export type CompanySkillInstallCatalog = z.infer<typeof companySkillInstallCatalogSchema>;
|
||||
export type CompanySkillInstallUpdate = z.infer<typeof companySkillInstallUpdateSchema>;
|
||||
export type CompanySkillReset = z.infer<typeof companySkillResetSchema>;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
|
||||
DOCUMENT_ANNOTATION_ANCHOR_STATES,
|
||||
DOCUMENT_ANNOTATION_THREAD_STATUSES,
|
||||
} from "../constants.js";
|
||||
import { multilineTextSchema } from "./text.js";
|
||||
|
||||
export const documentAnnotationThreadStatusSchema = z.enum(DOCUMENT_ANNOTATION_THREAD_STATUSES);
|
||||
export const documentAnnotationAnchorStateSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_STATES);
|
||||
export const documentAnnotationAnchorConfidenceSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES);
|
||||
|
||||
export const documentAnnotationTextQuoteSelectorSchema = z.object({
|
||||
exact: z.string().min(1).max(10_000),
|
||||
prefix: z.string().max(1_000).default(""),
|
||||
suffix: z.string().max(1_000).default(""),
|
||||
}).strict();
|
||||
|
||||
export const documentAnnotationTextPositionSelectorSchema = z.object({
|
||||
normalizedStart: z.number().int().nonnegative(),
|
||||
normalizedEnd: z.number().int().nonnegative(),
|
||||
markdownStart: z.number().int().nonnegative(),
|
||||
markdownEnd: z.number().int().nonnegative(),
|
||||
}).strict().superRefine((value, ctx) => {
|
||||
if (value.normalizedEnd <= value.normalizedStart) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "normalizedEnd must be greater than normalizedStart",
|
||||
path: ["normalizedEnd"],
|
||||
});
|
||||
}
|
||||
if (value.markdownEnd <= value.markdownStart) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "markdownEnd must be greater than markdownStart",
|
||||
path: ["markdownEnd"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const documentAnnotationAnchorSelectorSchema = z.object({
|
||||
quote: documentAnnotationTextQuoteSelectorSchema,
|
||||
position: documentAnnotationTextPositionSelectorSchema,
|
||||
}).strict();
|
||||
|
||||
export const createDocumentAnnotationThreadSchema = z.object({
|
||||
baseRevisionId: z.string().uuid(),
|
||||
baseRevisionNumber: z.number().int().positive(),
|
||||
selector: documentAnnotationAnchorSelectorSchema,
|
||||
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
||||
}).strict();
|
||||
|
||||
export const createDocumentAnnotationCommentSchema = z.object({
|
||||
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
|
||||
}).strict();
|
||||
|
||||
export const updateDocumentAnnotationThreadSchema = z.object({
|
||||
status: documentAnnotationThreadStatusSchema.optional(),
|
||||
}).strict().refine((value) => value.status != null, {
|
||||
message: "At least one field must be provided",
|
||||
});
|
||||
|
||||
export type CreateDocumentAnnotationThread = z.infer<typeof createDocumentAnnotationThreadSchema>;
|
||||
export type CreateDocumentAnnotationComment = z.infer<typeof createDocumentAnnotationCommentSchema>;
|
||||
export type UpdateDocumentAnnotationThread = z.infer<typeof updateDocumentAnnotationThreadSchema>;
|
||||
@@ -67,6 +67,8 @@ export {
|
||||
companySkillUsageAgentSchema,
|
||||
companySkillDetailSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
companySkillAuditFindingSchema,
|
||||
companySkillAuditResultSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillProjectScanSkippedSchema,
|
||||
@@ -75,10 +77,23 @@ export {
|
||||
companySkillCreateSchema,
|
||||
companySkillFileDetailSchema,
|
||||
companySkillFileUpdateSchema,
|
||||
catalogSkillKindSchema,
|
||||
catalogSkillFileSchema,
|
||||
catalogSkillSchema,
|
||||
catalogSkillListQuerySchema,
|
||||
catalogSkillFileDetailSchema,
|
||||
companySkillInstallCatalogSchema,
|
||||
companySkillInstallCatalogResultSchema,
|
||||
companySkillInstallUpdateSchema,
|
||||
companySkillResetSchema,
|
||||
type CompanySkillImport,
|
||||
type CompanySkillProjectScan,
|
||||
type CompanySkillCreate,
|
||||
type CompanySkillFileUpdate,
|
||||
type CatalogSkillListQuery,
|
||||
type CompanySkillInstallCatalog,
|
||||
type CompanySkillInstallUpdate,
|
||||
type CompanySkillReset,
|
||||
} from "./company-skill.js";
|
||||
export {
|
||||
agentSkillStateSchema,
|
||||
@@ -152,6 +167,21 @@ export {
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
} from "./project.js";
|
||||
|
||||
export {
|
||||
createDocumentAnnotationCommentSchema,
|
||||
createDocumentAnnotationThreadSchema,
|
||||
documentAnnotationAnchorConfidenceSchema,
|
||||
documentAnnotationAnchorSelectorSchema,
|
||||
documentAnnotationAnchorStateSchema,
|
||||
documentAnnotationTextPositionSelectorSchema,
|
||||
documentAnnotationTextQuoteSelectorSchema,
|
||||
documentAnnotationThreadStatusSchema,
|
||||
updateDocumentAnnotationThreadSchema,
|
||||
type CreateDocumentAnnotationComment,
|
||||
type CreateDocumentAnnotationThread,
|
||||
type UpdateDocumentAnnotationThread,
|
||||
} from "./document-annotation.js";
|
||||
|
||||
export {
|
||||
createIssueSchema,
|
||||
createIssueInputSchema,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: doc-maintenance
|
||||
description: Keep project docs aligned with recent code and feature changes — detect drift, update affected pages, and add release-relevant notes without rewriting unchanged sections.
|
||||
key: paperclipai/bundled/docs/doc-maintenance
|
||||
recommendedForRoles:
|
||||
- engineer
|
||||
- product
|
||||
- devrel
|
||||
tags:
|
||||
- docs
|
||||
- documentation
|
||||
- release-notes
|
||||
---
|
||||
|
||||
# Doc Maintenance
|
||||
|
||||
Keep the documentation honest with minimum churn. The goal is alignment between docs and behavior, not stylistic rewrites or cosmetic re-organization. Reviewers should be able to read a diff and see "this updates docs to match recent behavior changes".
|
||||
|
||||
## When to use
|
||||
|
||||
- A PR or recent set of merges changed user-visible behavior: CLI flags, API shapes, default values, configuration keys, endpoints, environment variables, supported versions.
|
||||
- A user-reported bug traced back to outdated documentation.
|
||||
- A release is being cut and the docs need a pass against the merged commits.
|
||||
- A new feature shipped but only the engineer's PR description describes how to use it.
|
||||
|
||||
## When not to use
|
||||
|
||||
- The change is internal-only (private helper rename, refactor) with no user-visible impact.
|
||||
- You want to "improve the docs" without a behavior anchor. That is a separate scoped project, not maintenance — make a plan first.
|
||||
|
||||
## The pass
|
||||
|
||||
1. **Establish the baseline.** Get the commit range you are documenting against (since last release tag, since last merged-doc commit, or since a specific PR).
|
||||
2. **Enumerate user-visible changes.** Read commits and PR descriptions. List, for each change, what a user can now do differently.
|
||||
3. **Map changes to docs.** For each change, find every page that mentions the affected concept. Common targets: README, CLI reference, API reference, configuration reference, migration guide, FAQ, examples.
|
||||
4. **Update precisely.** Edit only the lines that need to change. Do not rewrap paragraphs you did not modify — it pollutes the diff.
|
||||
5. **Add new entries where needed.** New CLI flag → CLI reference entry. New env var → configuration reference entry. New endpoint → API reference entry. Don't only add it to the changelog.
|
||||
6. **Update examples and snippets.** Code blocks in docs are wrong faster than prose. Re-run any example that touches new behavior.
|
||||
7. **Write the release note.** One sentence per user-visible change. Group by Added / Changed / Fixed / Deprecated / Removed. Link to the relevant PRs and docs section.
|
||||
8. **Cross-check.** Search the docs for the old behavior wording and remove or update stragglers.
|
||||
|
||||
## Style baseline
|
||||
|
||||
- Voice: second person ("you can pass `--json` to ..."). Avoid "we" except in narrative pages.
|
||||
- Tense: present, not future. The behavior exists once shipped.
|
||||
- Headings: imperative ("Configure the cache") or noun-phrase ("Cache configuration"), match the surrounding page.
|
||||
- Code blocks: include the language tag so syntax highlighting works.
|
||||
- Cross-links: link the first mention of a concept on each page; do not link every occurrence.
|
||||
- Avoid promising future behavior. If something is unreleased, mark it `experimental` or omit it.
|
||||
|
||||
## Drift detection
|
||||
|
||||
A doc page is drifting if any of these are true:
|
||||
|
||||
- It documents a flag, key, or endpoint that no longer exists.
|
||||
- An example does not run as written.
|
||||
- A default value in the docs does not match the code.
|
||||
- A supported-versions list excludes a version the project actually supports, or includes one it dropped.
|
||||
- A "Coming soon" section references a feature that shipped or was cancelled.
|
||||
|
||||
When you find drift, fix it in the same pass and note it in the release note's `Fixed` group.
|
||||
|
||||
## Release-note rules
|
||||
|
||||
- One sentence per item. If two sentences are needed, the item is likely two items.
|
||||
- User impact first, internal cause second. `Faster cold start (avoid full bundle download on first run)` beats `Refactor bootstrap loader`.
|
||||
- Link the PR for engineering readers and the docs page for users.
|
||||
- Mark breaking changes explicitly: `**Breaking:**` prefix. Include migration steps inline or via link.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Massive doc PRs that bundle stylistic rewrites with real updates. Reviewers cannot tell which lines reflect actual behavior changes.
|
||||
- "Updated docs" commit messages with no detail. Make the commit say what changed and why.
|
||||
- Adding to the changelog without updating the reference docs the changelog points to.
|
||||
- Marking a feature as available before its code lands. Documentation must follow behavior, not promise it.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: issue-triage
|
||||
description: Triage Paperclip inbox issues that are stale, blocked, in-review, or assigned-but-not-progressing, and decide a single next action per issue (resume, reassign, unblock, escalate, or close).
|
||||
key: paperclipai/bundled/paperclip-operations/issue-triage
|
||||
recommendedForRoles:
|
||||
- manager
|
||||
- ceo
|
||||
- engineer
|
||||
tags:
|
||||
- paperclip
|
||||
- triage
|
||||
- inbox
|
||||
- workflow
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
Convert a noisy inbox into a small set of clear next actions. Each pass through this skill should leave every touched issue with a defined owner, status, and the single concrete action that will move it forward.
|
||||
|
||||
## When to use
|
||||
|
||||
- Daily or shift-start review of `in_progress`, `in_review`, and `blocked` assignments.
|
||||
- An inbox has many open assignments and no clear priority.
|
||||
- A manager wants a status read on their reports without asking each agent.
|
||||
- You are woken by a comment that suggests an old issue stalled.
|
||||
|
||||
## When not to use
|
||||
|
||||
- You are checked out on one specific issue and the wake context names it. Work that issue, do not triage the whole inbox.
|
||||
- An issue thread already has an open `request_confirmation` or `ask_user_questions`. Wait for the response — re-triage is noise.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `GET /api/agents/me/inbox-lite` for the compact assignment list.
|
||||
- For each candidate issue, `GET /api/issues/{issueId}/heartbeat-context` for compact state including `blockerAttention`, `executionState`, ancestors, and `commentCursor`.
|
||||
- Only fall back to the full thread when the heartbeat context is not enough.
|
||||
|
||||
## Per-issue triage decision
|
||||
|
||||
For each issue, classify into exactly one of:
|
||||
|
||||
1. **Resume** — execution path is alive. Confirm the assignee is set and let the heartbeat continue. Do not comment.
|
||||
2. **Wake-needed** — assignee is stalled with no live continuation. Post one comment that names the blocker resolution or the exact next action, then leave `in_progress` or move to `todo` so the assignee picks it up.
|
||||
3. **Reassign** — the assignee is not the right specialty. Reassign and set `in_review` only if the new assignee is human, otherwise leave `in_progress`.
|
||||
4. **Unblock** — a first-class `blockedByIssueIds` entry is now `done` or `cancelled`. If `cancelled`, replace or remove it from `blockedByIssueIds`. The blockers-resolved wake will fire automatically when all are `done`.
|
||||
5. **Escalate** — the issue needs board, CTO, or user input. Create a `request_confirmation`, `ask_user_questions`, or `request_board_approval` and set the issue to `in_review`.
|
||||
6. **Close** — work is complete, duplicate, or no longer relevant. Set `done` or `cancelled` with a one-line reason.
|
||||
|
||||
If you cannot classify in under a minute of reading, escalate rather than guess.
|
||||
|
||||
## Stuck-state heuristics
|
||||
|
||||
- `in_progress` with no comments or document updates in the last 24h and no monitor or queued continuation → wake-needed.
|
||||
- `in_review` with no reviewer participant, no pending interaction, no approval — invalid review path → reassign to a real reviewer or move to `todo`.
|
||||
- `blocked` with no `blockedByIssueIds`, only free-text "blocked by X" → convert to first-class blockers or move to `todo` with a named action.
|
||||
- `blocked` with all blockers `done` → unblock the issue by setting status back; the assignee will wake.
|
||||
- Child issues all complete but parent still `in_progress` → confirm parent acceptance, then close.
|
||||
|
||||
## Don't-do list
|
||||
|
||||
- Do not @-mention agents during triage; mentions cost budget. Use direct reassignment instead.
|
||||
- Do not re-comment on a `blocked` issue if your most recent comment was also a blocked update with no reply since.
|
||||
- Do not cancel cross-team issues. Reassign to the responsible manager with a comment.
|
||||
- Do not change status without a comment that explains the change.
|
||||
|
||||
## Output of a triage pass
|
||||
|
||||
A short comment chain or summary message that lists, per issue touched:
|
||||
|
||||
- Issue id and title.
|
||||
- Verdict (resume / wake-needed / reassign / unblock / escalate / close).
|
||||
- The one action you took or asked for.
|
||||
|
||||
This is the bar for "the triage is done."
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: task-planning
|
||||
description: Turn a Paperclip issue or request into a structured implementation plan with child task graph, blockers, owners, and acceptance criteria, then save it as the issue `plan` document.
|
||||
key: paperclipai/bundled/paperclip-operations/task-planning
|
||||
recommendedForRoles:
|
||||
- manager
|
||||
- engineer
|
||||
- product
|
||||
tags:
|
||||
- paperclip
|
||||
- planning
|
||||
- issues
|
||||
- delegation
|
||||
---
|
||||
|
||||
# Task Planning
|
||||
|
||||
Produce implementation plans that the Paperclip executor can actually run: explicit child issues, real blockers, named owners, and a defined acceptance bar. Avoid plans that read well but cannot be split into work.
|
||||
|
||||
## When to use
|
||||
|
||||
- An issue asks you to "plan", "scope", "break down", "design the rollout", "propose the work", or similar.
|
||||
- A user wants a written plan before approving implementation.
|
||||
- A manager needs to delegate non-trivial work and the shape of the work is not obvious yet.
|
||||
- You inherited an issue too large to deliver in one heartbeat and need to split it.
|
||||
|
||||
## When not to use
|
||||
|
||||
- The issue is a single small change you can ship in the same heartbeat. Just ship it.
|
||||
- The issue is forensic ("why did this break"). Use a diagnosis skill first; plan only after the root cause is named.
|
||||
- A current `plan` document already exists and the change is minor. Update that document; do not start fresh.
|
||||
|
||||
## Outputs
|
||||
|
||||
1. An updated issue document with key `plan` (markdown).
|
||||
2. A short comment on the issue that links to the plan document and names the next action.
|
||||
3. Where the plan requires approval, an issue-thread interaction of kind `request_confirmation` bound to the latest plan revision.
|
||||
|
||||
Do not create implementation subtasks until the plan is accepted.
|
||||
|
||||
## Plan structure
|
||||
|
||||
Required sections, in order:
|
||||
|
||||
1. **Goal** — one paragraph. What changes for the user, the operator, or the system once this work lands.
|
||||
2. **Context reviewed** — bullet list of documents, files, and prior issues you read. Lets reviewers spot missing inputs.
|
||||
3. **Constraints and non-goals** — what must hold (compatibility, security, performance) and what this plan deliberately will not do.
|
||||
4. **Approach** — the chosen path, with a short rationale. If you considered alternatives, name them and why you rejected them.
|
||||
5. **Work breakdown** — ordered list of child issues. Each child has:
|
||||
- Title in imperative form.
|
||||
- Owner specialty (Engineer, QA, Designer, Security, DevRel, Manager, etc.).
|
||||
- Scope and deliverables.
|
||||
- Acceptance criteria.
|
||||
- Blocks/blocked-by relationships expressed by phase letter or child title.
|
||||
6. **Acceptance** — the bar for the parent issue. How the user knows the whole thing is done.
|
||||
7. **Risks and mitigations** — short list. Skip if there are none.
|
||||
8. **Deferrals** — what is intentionally pushed to follow-up issues, with why.
|
||||
|
||||
## Rules of thumb for splitting
|
||||
|
||||
- One child issue, one specialty. If two specialties have to coordinate inside the same issue, split it.
|
||||
- One child issue, one acceptance verdict. If a reviewer would say "this is half done", split it.
|
||||
- A child must be checkout-able by the owner from its title and description alone. Reviewers should not have to re-read the parent plan to understand a child.
|
||||
- Order children by real blocker chains, not by author preference. Parallel children should explicitly say `blockers: none`.
|
||||
- Avoid `polish` or `cleanup` child issues without acceptance criteria — they never close.
|
||||
|
||||
## Filing the plan
|
||||
|
||||
Use the Paperclip API to write the plan document, then comment:
|
||||
|
||||
- `PUT /api/issues/{issueId}/documents/plan` with the markdown body. If `plan` already exists, include the latest `baseRevisionId`.
|
||||
- `POST /api/issues/{issueId}/comments` with a short summary that links the plan: `/<prefix>/issues/<issue-id>#document-plan`.
|
||||
- If approval is required: `POST /api/issues/{issueId}/interactions` with `kind: request_confirmation`, `targetRevisionId` set to the new plan revision, `continuationPolicy: wake_assignee`, and `idempotencyKey: "confirmation:{issueId}:plan:{revisionId}"`.
|
||||
- Set the issue to `in_review` after creating the confirmation. Stay assigned so the acceptance wakes the planner.
|
||||
|
||||
When the plan is accepted, see the companion skill for converting accepted plans into Paperclip executable tasks.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Plan disguised as a description edit. Use the `plan` document.
|
||||
- "Phases A–Z" with no work breakdown inside the phases.
|
||||
- Children with descriptions that say "see parent" — they fail at delegation time.
|
||||
- Acceptance written as "code review approval". Reviewers need a behavior bar, not a process bar.
|
||||
- Plans that bury blocker chains in prose. Use explicit blocked-by lines.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: qa-acceptance
|
||||
description: Produce QA acceptance criteria and a manual validation plan for a feature change — golden path, edge cases, error states, performance limits, and explicit pass/fail evidence.
|
||||
key: paperclipai/bundled/quality/qa-acceptance
|
||||
recommendedForRoles:
|
||||
- qa
|
||||
- engineer
|
||||
- product
|
||||
tags:
|
||||
- qa
|
||||
- acceptance
|
||||
- validation
|
||||
- testing
|
||||
---
|
||||
|
||||
# QA Acceptance
|
||||
|
||||
Write acceptance criteria that a reviewer can run against the running app and decide pass or fail without asking the author. The criteria are the contract — automated tests cover correctness, QA covers feature-level behavior.
|
||||
|
||||
## When to use
|
||||
|
||||
- A feature change is heading to QA and needs a written validation plan.
|
||||
- A reviewer is asked to verify a PR that touches user-visible behavior.
|
||||
- An incident postmortem requires a regression check before reopen-prevention.
|
||||
- A release candidate needs a pre-cut smoke pass.
|
||||
|
||||
## When not to use
|
||||
|
||||
- The change is unit-test-only (utility refactor, internal naming). Acceptance criteria are unnecessary churn.
|
||||
- You are asked to write tests against API contracts. Use contract testing, not feature QA.
|
||||
|
||||
## Acceptance criteria format
|
||||
|
||||
Each criterion is a single, independently-verifiable statement:
|
||||
|
||||
```md
|
||||
- **Given** <starting state>, **when** <action>, **then** <observable outcome>.
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
- **Given** a CSV export with 0 rows, **when** the user clicks Export, **then** the file downloads with only the header row and the UI shows "Exported 0 rows".
|
||||
```
|
||||
|
||||
Avoid criteria that combine multiple `when`s or `then`s. Split them.
|
||||
|
||||
## What every plan must cover
|
||||
|
||||
1. **Golden path.** The most common successful flow, end to end.
|
||||
2. **Empty and minimum states.** Zero items, one item, missing optional inputs.
|
||||
3. **Boundary inputs.** Max length strings, max numeric values, unicode, RTL text where applicable.
|
||||
4. **Error states.** Network failure, permission denied, validation failures, conflict (409), not found (404).
|
||||
5. **Concurrency and ordering.** Two users acting at once, race against background jobs, refresh during mutation.
|
||||
6. **Performance envelope.** The largest realistic input the change must handle without UI hangs or timeouts.
|
||||
7. **Backward compatibility.** Existing data, existing URLs, persisted user preferences continue to work.
|
||||
8. **Telemetry and audit.** Events, logs, or activity entries the change is supposed to emit.
|
||||
|
||||
If a section is genuinely not applicable, write "N/A: <why>" — do not silently omit.
|
||||
|
||||
## Evidence
|
||||
|
||||
Each criterion needs evidence on the verification pass:
|
||||
|
||||
- Screenshot or short clip for UI behavior.
|
||||
- Copied console / network output for API behavior.
|
||||
- Log snippet or activity row for telemetry.
|
||||
- Timing measurement for performance criteria.
|
||||
|
||||
"Looks good to me" without evidence is not a pass.
|
||||
|
||||
## Quarantine and follow-up
|
||||
|
||||
- A failing criterion blocks acceptance unless explicitly waived by the owner with a tracked follow-up issue.
|
||||
- "Known issue" without a linked follow-up is not a waiver.
|
||||
- If you add a new criterion mid-pass, restart the pass — partial coverage hides regressions.
|
||||
|
||||
## Handoff back to the author
|
||||
|
||||
Return the validation plan with three sections:
|
||||
|
||||
- **Pass.** Criteria that passed, with one-line evidence summaries.
|
||||
- **Fail.** Criteria that failed, with the exact reproduction.
|
||||
- **Blocked.** Criteria you could not run, with why.
|
||||
|
||||
The author owns turning failures into either fixes or accepted deferrals.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Acceptance phrased as test plan ("write a Cypress test for X"). Acceptance is what is true after the change ships; tests are how you check.
|
||||
- Criteria that depend on inspecting implementation details (selectors, query plans). Stay observable.
|
||||
- Long checklists with no priority. Mark must-pass criteria distinctly from nice-to-have.
|
||||
- Validation reports that say "passed" with no evidence. Reviewers cannot audit those.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: github-pr-workflow
|
||||
description: Prepare a GitHub pull request from a feature branch — branch hygiene, commit shape, title/body, verification notes, screenshots for UI work, and replies to review comments.
|
||||
key: paperclipai/bundled/software-development/github-pr-workflow
|
||||
recommendedForRoles:
|
||||
- engineer
|
||||
tags:
|
||||
- github
|
||||
- pull-requests
|
||||
- code-review
|
||||
- release
|
||||
---
|
||||
|
||||
# GitHub Pull Request Workflow
|
||||
|
||||
Ship a PR a reviewer can land without follow-up clarifying questions. The aim is high signal in the title and body, evidence the change works, and clean replies when feedback comes in.
|
||||
|
||||
## When to use
|
||||
|
||||
- You are about to open a PR for a change that is functionally complete.
|
||||
- A reviewer left comments and you need to respond and push fixes.
|
||||
- A PR has been open more than a day and needs to be brought back into shape (stale conflicts, missing description, missing verification).
|
||||
|
||||
## When not to use
|
||||
|
||||
- The change is not yet functionally complete. Finish the work first; draft PRs that bounce on review are noise.
|
||||
- The repository uses a non-GitHub forge. Adjust to that forge's conventions; do not force GitHub-isms.
|
||||
|
||||
## Branch hygiene before opening
|
||||
|
||||
- Rebase or merge from the target base so the diff is current.
|
||||
- Squash WIP commits into reviewable units. Prefer one commit per logical change; do not force one-commit-per-PR if the work is genuinely multi-step.
|
||||
- Confirm tests, typecheck, and lint pass locally. Note any deliberate skips in the PR body.
|
||||
- Remove debug prints, commented-out code, and `TODO` markers that are not tracked.
|
||||
|
||||
## PR title
|
||||
|
||||
- Imperative mood, under 70 characters.
|
||||
- Lead with the user-visible change, not the file touched. `Allow CSV export from reports table` beats `Update reports.tsx`.
|
||||
- If the repo uses an issue prefix convention (`PAP-1234:`, `[security]`), follow it.
|
||||
- No trailing period.
|
||||
|
||||
## PR body
|
||||
|
||||
Use this structure:
|
||||
|
||||
```md
|
||||
## Summary
|
||||
- 1–3 bullets describing what changed and why.
|
||||
|
||||
## Implementation notes
|
||||
- Anything non-obvious in the diff: trade-offs, dropped alternatives, gotchas.
|
||||
- Migration or config implications.
|
||||
|
||||
## Verification
|
||||
- The exact commands or steps you ran.
|
||||
- Screenshots or short clips for UI changes (required if pixels moved).
|
||||
- Edge cases you exercised by hand.
|
||||
|
||||
## Risk and rollback
|
||||
- What breaks if this is reverted, and how to revert cleanly.
|
||||
```
|
||||
|
||||
Skip the `Risk and rollback` section only for clearly trivial PRs (typos, docs).
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- Tests passing in CI is necessary, not sufficient. Reviewers also need to know the change behaves correctly end to end.
|
||||
- For UI work, include screenshots of the golden path and one edge case. Tag dark and light mode if the project supports both.
|
||||
- For migrations, include a dry-run plan and reversal steps.
|
||||
- For performance changes, include a before/after measurement, not adjectives.
|
||||
|
||||
## Replying to review comments
|
||||
|
||||
- Reply on every comment, even with just "fixed in <commit-sha>" — silent fixes leave the reviewer guessing.
|
||||
- Push fixes as new commits while review is active; do not amend during review unless the reviewer agrees.
|
||||
- If you disagree with feedback, say so with one sentence of rationale and let the reviewer decide. Don't escalate over comments.
|
||||
- Re-request review explicitly after pushing changes.
|
||||
|
||||
## Merge checklist
|
||||
|
||||
- All required checks green.
|
||||
- All review comments resolved.
|
||||
- PR title/body still accurate (update if scope changed mid-review).
|
||||
- Linked issue moves to `in_review` or `done` per project convention.
|
||||
- Delete the branch after merge unless it is a long-lived integration branch.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- PR description that says "see commits". Reviewers should not need to read the log.
|
||||
- Mixing refactor and behavior change in the same PR with no separation in the body.
|
||||
- "Address feedback" commits that bundle unrelated edits. One commit per round of feedback is fine; one commit for everything in flight is not.
|
||||
- Force-pushing during active review without telling the reviewer.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: agent-browser
|
||||
description: Drive a real browser to inspect or interact with a web page or app — navigate, take screenshots, read console and network, fill simple forms — for verification tasks, not unattended automation.
|
||||
key: paperclipai/optional/browser/agent-browser
|
||||
recommendedForRoles:
|
||||
- qa
|
||||
- engineer
|
||||
- researcher
|
||||
tags:
|
||||
- browser
|
||||
- puppeteer
|
||||
- playwright
|
||||
- verification
|
||||
---
|
||||
|
||||
# Agent Browser
|
||||
|
||||
Use a controlled browser to verify behavior, capture evidence, or extract information from web pages that a static fetch cannot reach (SPAs, login-gated pages, dynamic content). This skill is about supervised verification, not unattended scraping.
|
||||
|
||||
## When to use
|
||||
|
||||
- You need a screenshot of a deployed page or a local dev server to confirm a UI change.
|
||||
- You need to read JavaScript-rendered content that `curl`/`wget` will not see.
|
||||
- A user reports a UI bug and you need to reproduce it interactively to capture console errors, network requests, or layout state.
|
||||
- You need to walk through a short flow (load page, click, observe) to verify acceptance criteria.
|
||||
|
||||
## When not to use
|
||||
|
||||
- The page is reachable as static HTML. Use `curl`/HTTP fetch — it is cheaper, faster, and more reliable.
|
||||
- The task is unattended large-scale scraping. That belongs to a dedicated scraper with rate limits, robots.txt handling, and a real user agent policy — not this skill.
|
||||
- The site is behind authentication you do not own credentials for, or whose terms of service prohibit automation.
|
||||
- The site involves sensitive accounts (banking, healthcare, government) where automation risks lockout or compliance issues.
|
||||
|
||||
## Before launching the browser
|
||||
|
||||
- Confirm the URL and what state should be true after navigation.
|
||||
- Decide what evidence is needed: full-page screenshot, viewport screenshot, console log, network trace, HTML snapshot, extracted text.
|
||||
- Decide the viewport size that matters for the task (mobile vs desktop). Default to a desktop size unless the task is mobile-specific.
|
||||
- For local dev servers, confirm the server is running and the port is what you expect.
|
||||
|
||||
## Driving the browser
|
||||
|
||||
A typical verification session:
|
||||
|
||||
1. **Launch with a real-looking user agent** when the target is the public internet; an unrealistic UA flags automation traffic.
|
||||
2. **Set a sane viewport** (e.g., 1366×768 desktop, 390×844 iPhone-ish).
|
||||
3. **Navigate and wait for the right signal.** Prefer waiting for a specific selector or network-idle over arbitrary sleeps.
|
||||
4. **Capture evidence immediately** after the wait condition succeeds, before any interaction perturbs the state.
|
||||
5. **Interact deliberately.** One click at a time, with a wait between actions; re-screenshot after each meaningful state change.
|
||||
6. **Read the console and network panels** for unexpected errors, 4xx/5xx responses, or slow requests.
|
||||
7. **Close the browser cleanly** when done. Long-running browser sessions leak memory and hold ports.
|
||||
|
||||
## What evidence to record
|
||||
|
||||
For a verification task, deliver:
|
||||
|
||||
- A full-page or viewport screenshot of each meaningful state.
|
||||
- The console log, filtered to warnings/errors.
|
||||
- Any non-2xx network response with the URL, status, and a short response body excerpt.
|
||||
- A short narration: "Navigated to X, observed Y, clicked Z, observed W."
|
||||
|
||||
For a UI bug repro, also record:
|
||||
|
||||
- The exact reproduction steps the user can follow.
|
||||
- Viewport size and (where relevant) device pixel ratio.
|
||||
- Whether the bug reproduces on first load vs after interaction.
|
||||
|
||||
## Login-gated pages
|
||||
|
||||
- Prefer programmatic auth (API token, magic link) over UI login.
|
||||
- If UI login is the only path, the user must provide credentials explicitly for this run. Never reuse credentials outside the session.
|
||||
- Do not store credentials in the session log, screenshot, or returned output.
|
||||
|
||||
## Performance and politeness
|
||||
|
||||
- Throttle to one navigation per few seconds when touching shared infra.
|
||||
- Respect `robots.txt` for public sites you are inspecting at any volume.
|
||||
- Cancel navigations if a page exceeds a reasonable timeout (e.g., 30s); the page is broken or rate-limiting you.
|
||||
- Do not retry forever on failure. Retry once with a longer timeout, then escalate.
|
||||
|
||||
## Common failure modes
|
||||
|
||||
- **Selector not found.** Page changed, or you are waiting before render. Take a screenshot to see actual state; adjust the selector.
|
||||
- **Click does nothing.** The element is offscreen, covered by a modal, or in a shadow DOM. Scroll into view or pierce the shadow root.
|
||||
- **Headless detection.** Some sites detect headless Chrome and serve a different page. Use a non-headless mode or a fingerprint-realistic configuration only when authorized.
|
||||
- **Cross-origin iframe blocking.** Iframes you do not own cannot be inspected; the page must offer the data outside the iframe or the task is infeasible.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Long unsupervised browser sessions that drift from the original task.
|
||||
- Scraping behind authentication you do not own.
|
||||
- Captioning a screenshot with "looks good" without saying what state was loaded and what selectors confirmed it.
|
||||
- Treating a passing screenshot as proof of correctness across viewports you did not actually test.
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: release-announcement
|
||||
description: Write a release announcement — changelog, blog post, in-app note, or social post — that leads with user impact, names the audience, and includes upgrade/migration steps without filler.
|
||||
key: paperclipai/optional/content/release-announcement
|
||||
recommendedForRoles:
|
||||
- devrel
|
||||
- product
|
||||
- writer
|
||||
tags:
|
||||
- release
|
||||
- changelog
|
||||
- announcement
|
||||
- communication
|
||||
---
|
||||
|
||||
# Release Announcement
|
||||
|
||||
Write the channel-appropriate announcement for a release without churn. Different surfaces need different shapes: a changelog entry is not a blog post is not a social card. The bar is: a reader of the chosen surface can decide in under 30 seconds whether this release affects them, and if so what to do.
|
||||
|
||||
## When to use
|
||||
|
||||
- A version, feature, or fix is shipping and needs writeup for at least one surface.
|
||||
- A previously private feature is going GA.
|
||||
- A breaking change needs broadcast before users hit it.
|
||||
|
||||
## When not to use
|
||||
|
||||
- An internal-only change with no user impact. Update internal docs; do not announce.
|
||||
- The release is incomplete (still in active development). Wait until it ships, even if marketing wants the post.
|
||||
|
||||
## Determine the audience and channel first
|
||||
|
||||
| Audience | Best channel | Tone |
|
||||
|---|---|---|
|
||||
| Existing power users | Changelog, in-app note | Terse, factual, links |
|
||||
| Engineering teams adopting your API | Release notes, dev blog | Examples, migration steps, version pins |
|
||||
| Prospective customers | Landing page, marketing blog | Story arc, problem → solution, social proof |
|
||||
| Broad audience | Social post, email newsletter | One-sentence pitch, link to depth |
|
||||
| Internal team | Slack/Discord post | What changed, who to ping if it breaks |
|
||||
|
||||
Pick the audience for *this* writeup. One release often needs several writeups; do not blend them.
|
||||
|
||||
## Universal structure
|
||||
|
||||
Whatever the channel, lead with:
|
||||
|
||||
1. **What changed.** One sentence in the user's vocabulary.
|
||||
2. **Who it affects.** Which user role / use case.
|
||||
3. **What to do.** Migrate now / opt-in / no action needed.
|
||||
|
||||
Everything else is depth that supports those three.
|
||||
|
||||
## Channel templates
|
||||
|
||||
### Changelog entry (terse)
|
||||
|
||||
```md
|
||||
## v1.42.0 — 2026-05-26
|
||||
|
||||
### Added
|
||||
- <feature> — <one-line user benefit>. ([#1234](link))
|
||||
|
||||
### Changed
|
||||
- <change> — <one-line impact>. ([#1235](link))
|
||||
|
||||
### Fixed
|
||||
- <bug> — <one-line user-visible symptom>. ([#1236](link))
|
||||
|
||||
### Deprecated
|
||||
- <thing>. Replaced by <thing>. Removal planned for v<x>.
|
||||
|
||||
### Breaking
|
||||
- <change>. **Migration:** <one-line> or <link to guide>.
|
||||
```
|
||||
|
||||
### Release notes (for adopters)
|
||||
|
||||
Same as changelog, plus:
|
||||
|
||||
- Migration guide section with before/after code.
|
||||
- Compatibility table (versions, runtimes, OS).
|
||||
- Known issues and workarounds.
|
||||
- Acknowledgements (contributors, reporters of fixed bugs).
|
||||
|
||||
### Dev blog post (300–800 words)
|
||||
|
||||
- **Hook (1 paragraph):** the problem the release solves, in a real-world scenario.
|
||||
- **What's new (3–5 bullets with sub-paragraphs):** features, with one code or screenshot example each.
|
||||
- **Upgrade (1 paragraph):** how to upgrade, what to check.
|
||||
- **What's next:** one sentence about the next direction. Avoid promises.
|
||||
|
||||
### In-app note
|
||||
|
||||
- 1 sentence.
|
||||
- 1 link.
|
||||
- Dismiss after seen.
|
||||
|
||||
### Social post
|
||||
|
||||
- 1 sentence pitch.
|
||||
- 1 link.
|
||||
- 1 image or short clip.
|
||||
- No threadbait. If it needs a thread, write a blog post instead.
|
||||
|
||||
## Writing rules
|
||||
|
||||
- Lead with the user, not the team. `You can now export to CSV` beats `We've added CSV export`.
|
||||
- Numbers beat adjectives. `60% faster cold start` beats `much faster`. Cite the methodology.
|
||||
- Show, don't just tell. One code snippet, one screenshot — more is noise.
|
||||
- Date the post. Undated release content rots fastest.
|
||||
- Link the migration path explicitly. Do not bury it.
|
||||
- Mark breaking changes with `**Breaking:**` prefix. Repeat in the email/social channel.
|
||||
|
||||
## Avoid
|
||||
|
||||
- "We are excited to announce" filler.
|
||||
- Lists of changes that mix user-visible and internal items.
|
||||
- Marketing claims without a way to verify.
|
||||
- Promised dates for unshipped work.
|
||||
- Pre-announcing something the team has not yet committed to ship.
|
||||
|
||||
## Post-publish checklist
|
||||
|
||||
- Changelog is in source control alongside the release.
|
||||
- Blog post date matches actual ship date.
|
||||
- All links work (release tag, PRs, docs sections).
|
||||
- Breaking changes are also in the upgrade guide, not only the post.
|
||||
- Internal team is notified before the public post goes live, not after.
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: design-critique
|
||||
description: Give a structured product design critique — user job clarity, hierarchy, affordance, error states, accessibility, and consistency — focused on what to change, in what order, and why.
|
||||
key: paperclipai/optional/product/design-critique
|
||||
recommendedForRoles:
|
||||
- designer
|
||||
- product
|
||||
- engineer
|
||||
tags:
|
||||
- design
|
||||
- product
|
||||
- ux
|
||||
- review
|
||||
---
|
||||
|
||||
# Product Design Critique
|
||||
|
||||
A structured critique pass for a screen, flow, or component. The output is a prioritized list of changes a designer or engineer can act on — not adjectives. Critique is not redesign; recommend, do not rebuild.
|
||||
|
||||
## When to use
|
||||
|
||||
- A designer or engineer asks for feedback on a screen, mock, or live UI.
|
||||
- A feature is shipping and someone wants a final UX read.
|
||||
- A flow is suspected of causing user drop-off and you want a pre-research read before instrumentation.
|
||||
|
||||
## When not to use
|
||||
|
||||
- The user wants a redesign. That is a design project, not a critique.
|
||||
- The work is so early that no concrete artifact exists. Sketch with them instead of critiquing air.
|
||||
- You have no context on the user job. Ask for it first; design critique without user context devolves into taste.
|
||||
|
||||
## Pre-critique context
|
||||
|
||||
Before opening a screen, get:
|
||||
|
||||
- **Who is the user.** Specific role and competence, not "users".
|
||||
- **What job they are doing on this screen.** One sentence.
|
||||
- **What success looks like.** What the user can do after this screen that they could not before.
|
||||
- **Where this screen sits in the larger flow.** What precedes and follows.
|
||||
|
||||
If any of these is missing, ask. Critique without these is opinion.
|
||||
|
||||
## The pass (in order)
|
||||
|
||||
1. **Clarity of the user job.**
|
||||
- Within 3 seconds of opening, is it obvious what this screen is for?
|
||||
- Does the primary action match the user's actual job, or a designer's preferred path?
|
||||
|
||||
2. **Visual hierarchy.**
|
||||
- The most important thing on the screen should be the most prominent (size, weight, position, color).
|
||||
- Secondary actions should look secondary. Tertiary should be findable but not loud.
|
||||
- Headings should chunk content into the right groups for the task.
|
||||
|
||||
3. **Affordance and signifiers.**
|
||||
- Clickable things look clickable.
|
||||
- Disabled things look disabled and explain why on hover/focus.
|
||||
- Drag, scroll, or swipe interactions are discoverable, not hidden.
|
||||
|
||||
4. **States.**
|
||||
- Empty state (no data) is designed, not a blank rectangle.
|
||||
- Loading state communicates progress, not just spins.
|
||||
- Error states say what went wrong and what to do next, in the user's words.
|
||||
- Success state confirms without celebrating banal actions.
|
||||
|
||||
5. **Inputs and forms.**
|
||||
- Labels visible, not just placeholders.
|
||||
- Validation runs at the right time (on blur, not on every keystroke unless the user is in a known-format field).
|
||||
- Required fields marked.
|
||||
- Field order matches the user's mental order, not the database order.
|
||||
|
||||
6. **Accessibility.**
|
||||
- Sufficient color contrast (WCAG AA at minimum; AAA where reasonable).
|
||||
- Focus order is logical for keyboard navigation.
|
||||
- Interactive elements are reachable without a mouse.
|
||||
- Critical information is not color-only (icons, text, position back it up).
|
||||
- Touch targets at least 44×44 px on mobile.
|
||||
|
||||
7. **Consistency.**
|
||||
- Tokens, components, and patterns match the rest of the product.
|
||||
- "Borrowed" patterns from other products are intentional, not accidental drift.
|
||||
|
||||
8. **Copy.**
|
||||
- Buttons are verbs that name the outcome ("Save changes" beats "Submit").
|
||||
- Microcopy explains, does not decorate.
|
||||
- Tone matches the product voice.
|
||||
|
||||
9. **Edge cases.**
|
||||
- Long content (long names, many items, RTL languages).
|
||||
- Tiny content (one item, zero items).
|
||||
- Slow network and offline behavior.
|
||||
- Permissions denied.
|
||||
|
||||
## Output format
|
||||
|
||||
Group findings by severity, then by category. Each finding is one issue and one suggested fix.
|
||||
|
||||
```md
|
||||
## Design critique: <screen name>
|
||||
|
||||
### Must-fix (blocks ship)
|
||||
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
|
||||
|
||||
### Should-fix (before broader rollout)
|
||||
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
|
||||
|
||||
### Nice-to-fix (when there's room)
|
||||
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
|
||||
|
||||
### Strengths to keep
|
||||
- <one-line thing the design got right>
|
||||
```
|
||||
|
||||
Always include the "strengths to keep" section. It is not flattery — it is signal to the designer about what not to change in the next round.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- "I would do it differently" without saying what or why. That is preference, not critique.
|
||||
- Long critiques that bury must-fix items under nice-to-haves.
|
||||
- Suggesting net-new features under the guise of a critique.
|
||||
- Ignoring user context and grading on taste.
|
||||
- Treating a critique as approval. State approval explicitly if asked; otherwise critique is feedback, not sign-off.
|
||||
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"packageName": "@paperclipai/skills-catalog",
|
||||
"packageVersion": "0.3.1",
|
||||
"generatedAt": "2026-05-28T03:02:49.579Z",
|
||||
"skills": [
|
||||
{
|
||||
"id": "paperclipai:bundled:docs:doc-maintenance",
|
||||
"key": "paperclipai/bundled/docs/doc-maintenance",
|
||||
"kind": "bundled",
|
||||
"category": "docs",
|
||||
"slug": "doc-maintenance",
|
||||
"name": "doc-maintenance",
|
||||
"description": "Keep project docs aligned with recent code and feature changes — detect drift, update affected pages, and add release-relevant notes without rewriting unchanged sections.",
|
||||
"path": "catalog/bundled/docs/doc-maintenance",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"engineer",
|
||||
"product",
|
||||
"devrel"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"docs",
|
||||
"documentation",
|
||||
"release-notes"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 4478,
|
||||
"sha256": "fb0353386c5e5e5e13bcbb3233f044e3dccecf371f429d6328f26c26d7cb6169"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:2e02299210fd17c1fe1867b4ee8c144a11b6fe1fe481f83b8268cfbaaf10f9aa"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:paperclip-operations:issue-triage",
|
||||
"key": "paperclipai/bundled/paperclip-operations/issue-triage",
|
||||
"kind": "bundled",
|
||||
"category": "paperclip-operations",
|
||||
"slug": "issue-triage",
|
||||
"name": "issue-triage",
|
||||
"description": "Triage Paperclip inbox issues that are stale, blocked, in-review, or assigned-but-not-progressing, and decide a single next action per issue (resume, reassign, unblock, escalate, or close).",
|
||||
"path": "catalog/bundled/paperclip-operations/issue-triage",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"manager",
|
||||
"ceo",
|
||||
"engineer"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"paperclip",
|
||||
"triage",
|
||||
"inbox",
|
||||
"workflow"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 4042,
|
||||
"sha256": "df5bdc8bf5e017b7ba5f70a4b5323fad51d0c323278f386580f26cf43ad09160"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:88dc13560371fb364963782cb4f6eeb4090fcde92ee3774479428ed6b90e11c1"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:paperclip-operations:task-planning",
|
||||
"key": "paperclipai/bundled/paperclip-operations/task-planning",
|
||||
"kind": "bundled",
|
||||
"category": "paperclip-operations",
|
||||
"slug": "task-planning",
|
||||
"name": "task-planning",
|
||||
"description": "Turn a Paperclip issue or request into a structured implementation plan with child task graph, blockers, owners, and acceptance criteria, then save it as the issue `plan` document.",
|
||||
"path": "catalog/bundled/paperclip-operations/task-planning",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"manager",
|
||||
"engineer",
|
||||
"product"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"paperclip",
|
||||
"planning",
|
||||
"issues",
|
||||
"delegation"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 4649,
|
||||
"sha256": "2ff61e12dfaa4cf8cc548529fd176f55f1b1f5292ff9dd3eb2cb331417ab5e4e"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:4fb46a4bcefad4fd46fae48c433ee497112509a8e19fb8a7745ead44d219b498"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:quality:qa-acceptance",
|
||||
"key": "paperclipai/bundled/quality/qa-acceptance",
|
||||
"kind": "bundled",
|
||||
"category": "quality",
|
||||
"slug": "qa-acceptance",
|
||||
"name": "qa-acceptance",
|
||||
"description": "Produce QA acceptance criteria and a manual validation plan for a feature change — golden path, edge cases, error states, performance limits, and explicit pass/fail evidence.",
|
||||
"path": "catalog/bundled/quality/qa-acceptance",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"qa",
|
||||
"engineer",
|
||||
"product"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"qa",
|
||||
"acceptance",
|
||||
"validation",
|
||||
"testing"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 3861,
|
||||
"sha256": "c631b437ab26d104af6cdb963d8f679a9341439041b3cb3ec8835f4ff551b378"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:32372dacaf62e93454b9855968c4eec96456ba78b509f450b3dfaa48e31ef356"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:bundled:software-development:github-pr-workflow",
|
||||
"key": "paperclipai/bundled/software-development/github-pr-workflow",
|
||||
"kind": "bundled",
|
||||
"category": "software-development",
|
||||
"slug": "github-pr-workflow",
|
||||
"name": "github-pr-workflow",
|
||||
"description": "Prepare a GitHub pull request from a feature branch — branch hygiene, commit shape, title/body, verification notes, screenshots for UI work, and replies to review comments.",
|
||||
"path": "catalog/bundled/software-development/github-pr-workflow",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"engineer"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"github",
|
||||
"pull-requests",
|
||||
"code-review",
|
||||
"release"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 3970,
|
||||
"sha256": "f498ec4ebb1779dea37adeb1db8a8b22316282798e35ee02e2fc5ff627d7e261"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:90f278c89aa0711be150c1cd2456ca25620d02f36995b113ca9837d756a37f6c"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:optional:browser:agent-browser",
|
||||
"key": "paperclipai/optional/browser/agent-browser",
|
||||
"kind": "optional",
|
||||
"category": "browser",
|
||||
"slug": "agent-browser",
|
||||
"name": "agent-browser",
|
||||
"description": "Drive a real browser to inspect or interact with a web page or app — navigate, take screenshots, read console and network, fill simple forms — for verification tasks, not unattended automation.",
|
||||
"path": "catalog/optional/browser/agent-browser",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"qa",
|
||||
"engineer",
|
||||
"researcher"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"browser",
|
||||
"puppeteer",
|
||||
"playwright",
|
||||
"verification"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 5133,
|
||||
"sha256": "362f7b9d02297782bc6f0c093f495b8a0304a75bcf4b42e5c280a42b1f757b7d"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:eabb2c9f7b5e1a27ebb1e05a711d61433a266478154cd671a685e99e67aadea2"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:optional:content:release-announcement",
|
||||
"key": "paperclipai/optional/content/release-announcement",
|
||||
"kind": "optional",
|
||||
"category": "content",
|
||||
"slug": "release-announcement",
|
||||
"name": "release-announcement",
|
||||
"description": "Write a release announcement — changelog, blog post, in-app note, or social post — that leads with user impact, names the audience, and includes upgrade/migration steps without filler.",
|
||||
"path": "catalog/optional/content/release-announcement",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"devrel",
|
||||
"product",
|
||||
"writer"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"release",
|
||||
"changelog",
|
||||
"announcement",
|
||||
"communication"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 4416,
|
||||
"sha256": "062810ac34e9edc89efa701fec2eee60f16949d1944cc2cae49803cb91e8cbf4"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:f22a9ed696e6614c6db2757a149f48b3295e81f78c27d065d9cb164cf4f8a9bd"
|
||||
},
|
||||
{
|
||||
"id": "paperclipai:optional:product:design-critique",
|
||||
"key": "paperclipai/optional/product/design-critique",
|
||||
"kind": "optional",
|
||||
"category": "product",
|
||||
"slug": "design-critique",
|
||||
"name": "design-critique",
|
||||
"description": "Give a structured product design critique — user job clarity, hierarchy, affordance, error states, accessibility, and consistency — focused on what to change, in what order, and why.",
|
||||
"path": "catalog/optional/product/design-critique",
|
||||
"entrypoint": "SKILL.md",
|
||||
"trustLevel": "markdown_only",
|
||||
"compatibility": "compatible",
|
||||
"defaultInstall": false,
|
||||
"recommendedForRoles": [
|
||||
"designer",
|
||||
"product",
|
||||
"engineer"
|
||||
],
|
||||
"requires": [],
|
||||
"tags": [
|
||||
"design",
|
||||
"product",
|
||||
"ux",
|
||||
"review"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"kind": "skill",
|
||||
"sizeBytes": 4851,
|
||||
"sha256": "022e619baf6cc25725946279cb8052d22af090dd6cd6dc8c20f17867f71a5d8e"
|
||||
}
|
||||
],
|
||||
"contentHash": "sha256:429f94df398a0697042b5bbe4755b1ff1a230aa5f41d99118ad37493ac65d21c"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@paperclipai/skills-catalog",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/skills-catalog"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./catalog.json": "./generated/catalog.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"import": "./dist/src/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/src/types.d.ts",
|
||||
"import": "./dist/src/types.js"
|
||||
},
|
||||
"./catalog.json": "./dist/generated/catalog.json"
|
||||
},
|
||||
"main": "./dist/src/index.js",
|
||||
"types": "./dist/src/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"catalog",
|
||||
"dist",
|
||||
"generated"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm run build:manifest && tsc -p tsconfig.json",
|
||||
"build:manifest": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/build-catalog-manifest.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "pnpm -w exec vitest run --root packages/skills-catalog --config vitest.config.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"validate": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/validate-catalog.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { writeCatalogManifest } from "../src/catalog-builder.js";
|
||||
|
||||
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const result = await writeCatalogManifest(packageDir);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
for (const error of result.errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Wrote generated/catalog.json with ${result.manifest.skills.length} catalog skills.`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { validateCatalog } from "../src/catalog-builder.js";
|
||||
|
||||
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const result = await validateCatalog(packageDir);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
for (const error of result.errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Catalog manifest is valid with ${result.manifest.skills.length} catalog skills.`);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCatalogManifest,
|
||||
formatCatalogManifest,
|
||||
validateCatalog,
|
||||
} from "./catalog-builder.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe("skills catalog manifest", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("builds stable manifest entries from catalog skill directories", async () => {
|
||||
const packageDir = await createCatalogPackage();
|
||||
await writeSkill(packageDir, "bundled", "software-development", "github-pr-workflow", {
|
||||
frontmatter: [
|
||||
"name: GitHub PR Workflow",
|
||||
"description: Prepare pull requests and verification notes.",
|
||||
"key: paperclipai/bundled/software-development/github-pr-workflow",
|
||||
"recommendedForRoles:",
|
||||
" - engineer",
|
||||
"tags:",
|
||||
" - github",
|
||||
" - pull-requests",
|
||||
],
|
||||
files: {
|
||||
"references/checklist.md": "# Checklist\n",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await buildCatalogManifest({
|
||||
packageDir,
|
||||
generatedAt: "2026-05-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.manifest.skills).toHaveLength(1);
|
||||
expect(result.manifest.skills[0]).toMatchObject({
|
||||
id: "paperclipai:bundled:software-development:github-pr-workflow",
|
||||
key: "paperclipai/bundled/software-development/github-pr-workflow",
|
||||
kind: "bundled",
|
||||
category: "software-development",
|
||||
slug: "github-pr-workflow",
|
||||
name: "GitHub PR Workflow",
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
recommendedForRoles: ["engineer"],
|
||||
tags: ["github", "pull-requests"],
|
||||
});
|
||||
expect(result.manifest.skills[0]!.files.map((file) => file.path)).toEqual([
|
||||
"SKILL.md",
|
||||
"references/checklist.md",
|
||||
]);
|
||||
expect(result.manifest.skills[0]!.contentHash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("reports frontmatter, directory, uniqueness, and inventory errors together", async () => {
|
||||
const packageDir = await createCatalogPackage();
|
||||
await writeSkill(packageDir, "bundled", "Bad_Category", "duplicate", {
|
||||
frontmatter: [
|
||||
"name: Duplicate",
|
||||
"key: paperclipai/bundled/software-development/other",
|
||||
"recommendedForRoles: engineer",
|
||||
],
|
||||
});
|
||||
await writeSkill(packageDir, "optional", "software-development", "duplicate", {
|
||||
frontmatter: [
|
||||
"name: Duplicate Optional",
|
||||
"description: Optional duplicate slug.",
|
||||
],
|
||||
});
|
||||
await fs.mkdir(path.join(packageDir, "catalog", "bundled", "software-development", "missing-skill"), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(packageDir, "catalog", "misc"), { recursive: true });
|
||||
await fs.writeFile(path.join(packageDir, "catalog", "misc", "SKILL.md"), "# Misplaced\n", "utf8");
|
||||
|
||||
const result = await buildCatalogManifest({
|
||||
packageDir,
|
||||
generatedAt: "2026-05-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("catalog/misc/SKILL.md is not under catalog/<bundled|optional>/<category>/<slug>/SKILL.md"),
|
||||
expect.stringContaining("catalog/bundled/software-development/missing-skill is missing SKILL.md"),
|
||||
expect.stringContaining("has invalid category"),
|
||||
expect.stringContaining("frontmatter must include description"),
|
||||
expect.stringContaining("key must be paperclipai/bundled/Bad_Category/duplicate"),
|
||||
expect.stringContaining("field recommendedForRoles must be an array of strings"),
|
||||
expect.stringContaining("Duplicate catalog slug \"duplicate\""),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects stale generated manifests", async () => {
|
||||
const packageDir = await createCatalogPackage();
|
||||
await writeSkill(packageDir, "bundled", "software-development", "review", {
|
||||
frontmatter: [
|
||||
"name: Review",
|
||||
"description: Review implementation work.",
|
||||
],
|
||||
});
|
||||
await fs.mkdir(path.join(packageDir, "generated"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "generated", "catalog.json"),
|
||||
formatCatalogManifest({
|
||||
schemaVersion: 1,
|
||||
packageName: "@paperclipai/skills-catalog",
|
||||
packageVersion: "0.3.1",
|
||||
generatedAt: "2026-05-26T00:00:00.000Z",
|
||||
skills: [],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await validateCatalog(packageDir);
|
||||
|
||||
expect(result.errors).toContain(
|
||||
"generated/catalog.json is stale. Run pnpm --filter @paperclipai/skills-catalog build:manifest.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function createCatalogPackage() {
|
||||
const packageDir = await fs.mkdtemp(path.join(os.tmpdir(), "skills-catalog-"));
|
||||
tempDirs.push(packageDir);
|
||||
await fs.mkdir(path.join(packageDir, "catalog", "bundled"), { recursive: true });
|
||||
await fs.mkdir(path.join(packageDir, "catalog", "optional"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ version: "0.3.1" }),
|
||||
"utf8",
|
||||
);
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
async function writeSkill(
|
||||
packageDir: string,
|
||||
kind: "bundled" | "optional",
|
||||
category: string,
|
||||
slug: string,
|
||||
options: {
|
||||
frontmatter: string[];
|
||||
files?: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
const skillDir = path.join(packageDir, "catalog", kind, category, slug);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---\n${options.frontmatter.join("\n")}\n---\n\nUse this skill.\n`,
|
||||
"utf8",
|
||||
);
|
||||
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
|
||||
const filePath = path.join(skillDir, relativePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
asBoolean,
|
||||
asString,
|
||||
asStringArray,
|
||||
parseFrontmatterMarkdown,
|
||||
} from "./frontmatter.js";
|
||||
import type {
|
||||
CatalogManifest,
|
||||
CatalogSkill,
|
||||
CatalogSkillFile,
|
||||
CatalogSkillFileKind,
|
||||
CatalogSkillKind,
|
||||
CatalogTrustLevel,
|
||||
} from "./types.js";
|
||||
|
||||
const CATALOG_PACKAGE_NAME = "@paperclipai/skills-catalog";
|
||||
const CATALOG_SCHEMA_VERSION = 1;
|
||||
const SKILL_ENTRYPOINT = "SKILL.md";
|
||||
const MAX_CATALOG_FILE_BYTES = 1024 * 1024;
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const CATALOG_KINDS = new Set<CatalogSkillKind>(["bundled", "optional"]);
|
||||
|
||||
interface SkillCandidate {
|
||||
kind: CatalogSkillKind;
|
||||
category: string;
|
||||
slug: string;
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
interface BuildCatalogManifestOptions {
|
||||
packageDir: string;
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
interface BuildCatalogManifestResult {
|
||||
manifest: CatalogManifest;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function formatCatalogManifest(manifest: CatalogManifest): string {
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export async function buildExpectedCatalogManifest(
|
||||
packageDir: string,
|
||||
): Promise<BuildCatalogManifestResult> {
|
||||
const existing = await readExistingManifest(packageDir);
|
||||
const firstPass = await buildCatalogManifest({
|
||||
packageDir,
|
||||
generatedAt: existing?.generatedAt ?? new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (existing && sameManifestExceptGeneratedAt(existing, firstPass.manifest)) {
|
||||
return firstPass;
|
||||
}
|
||||
|
||||
return buildCatalogManifest({
|
||||
packageDir,
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildCatalogManifest(
|
||||
options: BuildCatalogManifestOptions,
|
||||
): Promise<BuildCatalogManifestResult> {
|
||||
const packageDir = path.resolve(options.packageDir);
|
||||
const packageJson = await readPackageJson(packageDir);
|
||||
const errors: string[] = [];
|
||||
const candidates = await discoverSkillCandidates(packageDir, errors);
|
||||
const skills: CatalogSkill[] = [];
|
||||
|
||||
collectCandidateUniquenessErrors(candidates, errors);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const skill = await buildCatalogSkill(packageDir, candidate, errors);
|
||||
if (skill) skills.push(skill);
|
||||
}
|
||||
|
||||
skills.sort((a, b) => a.id.localeCompare(b.id));
|
||||
collectUniquenessErrors(skills, errors);
|
||||
|
||||
return {
|
||||
manifest: {
|
||||
schemaVersion: CATALOG_SCHEMA_VERSION,
|
||||
packageName: CATALOG_PACKAGE_NAME,
|
||||
packageVersion: packageJson.version,
|
||||
generatedAt: options.generatedAt ?? new Date().toISOString(),
|
||||
skills,
|
||||
},
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateCatalog(packageDir: string): Promise<BuildCatalogManifestResult> {
|
||||
const expected = await buildExpectedCatalogManifest(packageDir);
|
||||
const generatedPath = path.join(packageDir, "generated", "catalog.json");
|
||||
const errors = [...expected.errors];
|
||||
|
||||
let generatedText: string | null = null;
|
||||
try {
|
||||
generatedText = await fs.readFile(generatedPath, "utf8");
|
||||
JSON.parse(generatedText);
|
||||
} catch (error) {
|
||||
errors.push(`generated/catalog.json is missing or invalid: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
if (generatedText !== null) {
|
||||
const expectedText = formatCatalogManifest(expected.manifest);
|
||||
if (generatedText !== expectedText) {
|
||||
errors.push("generated/catalog.json is stale. Run pnpm --filter @paperclipai/skills-catalog build:manifest.");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifest: expected.manifest,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCatalogManifest(packageDir: string) {
|
||||
const result = await buildExpectedCatalogManifest(packageDir);
|
||||
if (result.errors.length > 0) return result;
|
||||
|
||||
const generatedDir = path.join(packageDir, "generated");
|
||||
await fs.mkdir(generatedDir, { recursive: true });
|
||||
await fs.writeFile(path.join(generatedDir, "catalog.json"), formatCatalogManifest(result.manifest), "utf8");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readPackageJson(packageDir: string) {
|
||||
const packageJsonPath = path.join(packageDir, "package.json");
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
const version = asString(packageJson.version);
|
||||
if (!version) throw new Error(`${packageJsonPath} must declare a package version.`);
|
||||
return { version };
|
||||
}
|
||||
|
||||
async function readExistingManifest(packageDir: string): Promise<CatalogManifest | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(packageDir, "generated", "catalog.json"), "utf8")) as CatalogManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSkillCandidates(packageDir: string, errors: string[]) {
|
||||
const catalogDir = path.join(packageDir, "catalog");
|
||||
const candidates: SkillCandidate[] = [];
|
||||
|
||||
if (!existsSync(catalogDir)) {
|
||||
errors.push("catalog directory is missing.");
|
||||
return candidates;
|
||||
}
|
||||
|
||||
await collectMisplacedSkillFiles(catalogDir, errors);
|
||||
|
||||
for (const kind of ["bundled", "optional"] as const) {
|
||||
const kindDir = path.join(catalogDir, kind);
|
||||
if (!existsSync(kindDir)) continue;
|
||||
|
||||
for (const categoryEntry of await sortedDirEntries(kindDir)) {
|
||||
if (!categoryEntry.isDirectory()) continue;
|
||||
const category = categoryEntry.name;
|
||||
const categoryDir = path.join(kindDir, category);
|
||||
|
||||
for (const slugEntry of await sortedDirEntries(categoryDir)) {
|
||||
if (!slugEntry.isDirectory()) continue;
|
||||
const slug = slugEntry.name;
|
||||
const skillDir = path.join(categoryDir, slug);
|
||||
if (!existsSync(path.join(skillDir, SKILL_ENTRYPOINT))) {
|
||||
errors.push(`${relativePackagePath(packageDir, skillDir)} is missing SKILL.md.`);
|
||||
continue;
|
||||
}
|
||||
candidates.push({ kind, category, slug, absolutePath: skillDir });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function collectMisplacedSkillFiles(catalogDir: string, errors: string[]) {
|
||||
async function visit(dir: string) {
|
||||
for (const entry of await sortedDirEntries(dir)) {
|
||||
const absolutePath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (entry.name !== SKILL_ENTRYPOINT) continue;
|
||||
|
||||
const relativePath = toPosixPath(path.relative(catalogDir, absolutePath));
|
||||
const parts = relativePath.split("/");
|
||||
const kind = parts[0];
|
||||
if (parts.length !== 4 || !CATALOG_KINDS.has(kind as CatalogSkillKind)) {
|
||||
errors.push(`catalog/${relativePath} is not under catalog/<bundled|optional>/<category>/<slug>/SKILL.md.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await visit(catalogDir);
|
||||
}
|
||||
|
||||
async function buildCatalogSkill(
|
||||
packageDir: string,
|
||||
candidate: SkillCandidate,
|
||||
errors: string[],
|
||||
): Promise<CatalogSkill | null> {
|
||||
const prefix = relativePackagePath(packageDir, candidate.absolutePath);
|
||||
validateSlug("category", candidate.category, prefix, errors);
|
||||
validateSlug("slug", candidate.slug, prefix, errors);
|
||||
|
||||
const id = `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`;
|
||||
const key = `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`;
|
||||
const skillMarkdownPath = path.join(candidate.absolutePath, SKILL_ENTRYPOINT);
|
||||
const parsed = parseFrontmatterMarkdown(await fs.readFile(skillMarkdownPath, "utf8"));
|
||||
|
||||
if (!parsed.hasFrontmatter) {
|
||||
errors.push(`${prefix}/SKILL.md must start with YAML frontmatter.`);
|
||||
}
|
||||
|
||||
const name = asString(parsed.frontmatter.name);
|
||||
if (!name) errors.push(`${prefix}/SKILL.md frontmatter must include name.`);
|
||||
|
||||
const description = asString(parsed.frontmatter.description);
|
||||
if (!description) errors.push(`${prefix}/SKILL.md frontmatter must include description.`);
|
||||
|
||||
const explicitKey = asString(parsed.frontmatter.key);
|
||||
if (explicitKey && explicitKey !== key) {
|
||||
errors.push(`${prefix}/SKILL.md key must be ${key}.`);
|
||||
}
|
||||
|
||||
const explicitSlug = asString(parsed.frontmatter.slug);
|
||||
if (explicitSlug && explicitSlug !== candidate.slug) {
|
||||
errors.push(`${prefix}/SKILL.md slug must be ${candidate.slug}.`);
|
||||
}
|
||||
|
||||
const defaultInstall = asBoolean(parsed.frontmatter.defaultInstall) ?? false;
|
||||
const recommendedForRoles = readStringArrayField(parsed.frontmatter.recommendedForRoles, "recommendedForRoles", prefix, errors);
|
||||
const requires = readStringArrayField(parsed.frontmatter.requires, "requires", prefix, errors);
|
||||
const tags = readStringArrayField(parsed.frontmatter.tags, "tags", prefix, errors);
|
||||
const files = await collectSkillFiles(packageDir, candidate.absolutePath, prefix, errors);
|
||||
|
||||
if (!name || !description) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
key,
|
||||
kind: candidate.kind,
|
||||
category: candidate.category,
|
||||
slug: candidate.slug,
|
||||
name,
|
||||
description,
|
||||
path: toPosixPath(path.relative(packageDir, candidate.absolutePath)),
|
||||
entrypoint: SKILL_ENTRYPOINT,
|
||||
trustLevel: deriveTrustLevel(files),
|
||||
compatibility: "compatible",
|
||||
defaultInstall,
|
||||
recommendedForRoles,
|
||||
requires,
|
||||
tags,
|
||||
files,
|
||||
contentHash: buildContentHash(files),
|
||||
};
|
||||
}
|
||||
|
||||
async function collectSkillFiles(
|
||||
packageDir: string,
|
||||
skillDir: string,
|
||||
prefix: string,
|
||||
errors: string[],
|
||||
): Promise<CatalogSkillFile[]> {
|
||||
const files: CatalogSkillFile[] = [];
|
||||
const skillRoot = await fs.realpath(skillDir);
|
||||
|
||||
async function visit(dir: string) {
|
||||
for (const entry of await sortedDirEntries(dir)) {
|
||||
const absolutePath = path.join(dir, entry.name);
|
||||
const lstat = await fs.lstat(absolutePath);
|
||||
let stat = lstat;
|
||||
let realPath = absolutePath;
|
||||
|
||||
if (lstat.isSymbolicLink()) {
|
||||
try {
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
stat = await fs.stat(absolutePath);
|
||||
} catch {
|
||||
errors.push(`${relativePackagePath(packageDir, absolutePath)} is a broken symlink.`);
|
||||
continue;
|
||||
}
|
||||
if (!isPathInside(skillRoot, realPath)) {
|
||||
errors.push(`${relativePackagePath(packageDir, absolutePath)} points outside its skill directory.`);
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
errors.push(`${relativePackagePath(packageDir, absolutePath)} is a directory symlink; copy files into the skill directory instead.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await visit(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
|
||||
const relativePath = toPosixPath(path.relative(skillDir, absolutePath));
|
||||
if (path.isAbsolute(relativePath) || relativePath.split("/").includes("..")) {
|
||||
errors.push(`${prefix}/${relativePath} has an invalid inventory path.`);
|
||||
continue;
|
||||
}
|
||||
if (stat.size > MAX_CATALOG_FILE_BYTES) {
|
||||
errors.push(`${prefix}/${relativePath} exceeds ${MAX_CATALOG_FILE_BYTES} bytes.`);
|
||||
}
|
||||
|
||||
const contents = await fs.readFile(absolutePath);
|
||||
files.push({
|
||||
path: relativePath,
|
||||
kind: classifyCatalogFile(relativePath),
|
||||
sizeBytes: stat.size,
|
||||
sha256: sha256(contents),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await visit(skillDir);
|
||||
files.sort((a, b) => {
|
||||
if (a.path === SKILL_ENTRYPOINT) return -1;
|
||||
if (b.path === SKILL_ENTRYPOINT) return 1;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
if (!files.some((file) => file.path === SKILL_ENTRYPOINT && file.kind === "skill")) {
|
||||
errors.push(`${prefix} inventory does not contain SKILL.md.`);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function readStringArrayField(
|
||||
value: unknown,
|
||||
field: string,
|
||||
prefix: string,
|
||||
errors: string[],
|
||||
) {
|
||||
const parsed = asStringArray(value);
|
||||
if (!parsed) {
|
||||
errors.push(`${prefix}/SKILL.md frontmatter field ${field} must be an array of strings.`);
|
||||
return [];
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function classifyCatalogFile(relativePath: string): CatalogSkillFileKind {
|
||||
if (relativePath === SKILL_ENTRYPOINT) return "skill";
|
||||
if (relativePath.startsWith("references/")) return "reference";
|
||||
if (relativePath.startsWith("scripts/")) return "script";
|
||||
if (relativePath.startsWith("assets/")) return "asset";
|
||||
if (relativePath.endsWith(".md") || relativePath.endsWith(".mdx")) return "markdown";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function deriveTrustLevel(files: CatalogSkillFile[]): CatalogTrustLevel {
|
||||
if (files.some((file) => file.kind === "script")) return "scripts_executables";
|
||||
if (files.some((file) => file.kind === "asset" || file.kind === "other")) return "assets";
|
||||
return "markdown_only";
|
||||
}
|
||||
|
||||
function buildContentHash(files: CatalogSkillFile[]) {
|
||||
const hashInput = files.map((file) => ({
|
||||
path: file.path,
|
||||
sha256: file.sha256,
|
||||
}));
|
||||
return `sha256:${sha256(Buffer.from(JSON.stringify(hashInput)))}`;
|
||||
}
|
||||
|
||||
function collectUniquenessErrors(skills: CatalogSkill[], errors: string[]) {
|
||||
collectDuplicateErrors(skills, "id", errors);
|
||||
collectDuplicateErrors(skills, "key", errors);
|
||||
collectDuplicateErrors(skills, "slug", errors);
|
||||
}
|
||||
|
||||
function collectCandidateUniquenessErrors(candidates: SkillCandidate[], errors: string[]) {
|
||||
const projected = candidates.map((candidate) => ({
|
||||
id: `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`,
|
||||
key: `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`,
|
||||
slug: candidate.slug,
|
||||
path: toPosixPath(path.join("catalog", candidate.kind, candidate.category, candidate.slug)),
|
||||
})) as CatalogSkill[];
|
||||
collectUniquenessErrors(projected, errors);
|
||||
}
|
||||
|
||||
function collectDuplicateErrors(fieldSkills: CatalogSkill[], field: "id" | "key" | "slug", errors: string[]) {
|
||||
const seen = new Map<string, string>();
|
||||
for (const skill of fieldSkills) {
|
||||
const value = skill[field];
|
||||
const first = seen.get(value);
|
||||
if (first) {
|
||||
errors.push(`Duplicate catalog ${field} "${value}" in ${first} and ${skill.path}.`);
|
||||
continue;
|
||||
}
|
||||
seen.set(value, skill.path);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSlug(label: string, value: string, prefix: string, errors: string[]) {
|
||||
if (!SLUG_PATTERN.test(value)) {
|
||||
errors.push(`${prefix} has invalid ${label} "${value}"; use lowercase URL slugs.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sortedDirEntries(dir: string) {
|
||||
return (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function sameManifestExceptGeneratedAt(a: CatalogManifest, b: CatalogManifest) {
|
||||
return JSON.stringify({ ...a, generatedAt: "" }) === JSON.stringify({ ...b, generatedAt: "" });
|
||||
}
|
||||
|
||||
function sha256(contents: Buffer) {
|
||||
return createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
function relativePackagePath(packageDir: string, absolutePath: string) {
|
||||
return toPosixPath(path.relative(packageDir, absolutePath));
|
||||
}
|
||||
|
||||
function toPosixPath(input: string) {
|
||||
return input.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string) {
|
||||
const relativePath = path.relative(parent, child);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export interface MarkdownDoc {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
hasFrontmatter: boolean;
|
||||
}
|
||||
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] | null {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) return null;
|
||||
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
const text = asString(item);
|
||||
if (!text) return null;
|
||||
out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) {
|
||||
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
|
||||
}
|
||||
|
||||
const closing = normalized.indexOf("\n---\n", 4);
|
||||
if (closing < 0) {
|
||||
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
|
||||
}
|
||||
|
||||
const frontmatterRaw = normalized.slice(4, closing).trim();
|
||||
const body = normalized.slice(closing + 5).trim();
|
||||
return {
|
||||
frontmatter: parseYamlFrontmatter(frontmatterRaw),
|
||||
body,
|
||||
hasFrontmatter: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
|
||||
const prepared = prepareYamlLines(raw);
|
||||
if (prepared.length === 0) return {};
|
||||
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
|
||||
return isPlainRecord(parsed.value) ? parsed.value : {};
|
||||
}
|
||||
|
||||
function prepareYamlLines(raw: string) {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) => ({
|
||||
indent: line.match(/^ */)?.[0].length ?? 0,
|
||||
content: line.trim(),
|
||||
}))
|
||||
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
|
||||
}
|
||||
|
||||
function parseYamlBlock(
|
||||
lines: Array<{ indent: number; content: string }>,
|
||||
startIndex: number,
|
||||
indentLevel: number,
|
||||
): { value: unknown; nextIndex: number } {
|
||||
let index = startIndex;
|
||||
if (index >= lines.length || lines[index]!.indent < indentLevel) {
|
||||
return { value: {}, nextIndex: index };
|
||||
}
|
||||
|
||||
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
|
||||
if (isArray) {
|
||||
const values: unknown[] = [];
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!;
|
||||
if (line.indent < indentLevel) break;
|
||||
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
|
||||
|
||||
const remainder = line.content.slice(1).trim();
|
||||
index += 1;
|
||||
if (!remainder) {
|
||||
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||
values.push(nested.value);
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
values.push(parseYamlScalar(remainder));
|
||||
}
|
||||
return { value: values, nextIndex: index };
|
||||
}
|
||||
|
||||
const record: Record<string, unknown> = {};
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!;
|
||||
if (line.indent < indentLevel) break;
|
||||
if (line.indent !== indentLevel) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.content.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.content.slice(0, separatorIndex).trim();
|
||||
const remainder = line.content.slice(separatorIndex + 1).trim();
|
||||
index += 1;
|
||||
if (!remainder) {
|
||||
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||
record[key] = nested.value;
|
||||
index = nested.nextIndex;
|
||||
continue;
|
||||
}
|
||||
record[key] = parseYamlScalar(remainder);
|
||||
}
|
||||
|
||||
return { value: record, nextIndex: index };
|
||||
}
|
||||
|
||||
function parseYamlScalar(rawValue: string): unknown {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed === "") return "";
|
||||
if (trimmed === "null" || trimmed === "~") return null;
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (trimmed === "[]") return [];
|
||||
if (trimmed === "{}") return {};
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
if (
|
||||
trimmed.startsWith("\"") ||
|
||||
trimmed.startsWith("[") ||
|
||||
trimmed.startsWith("{")
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import catalogManifestJson from "../generated/catalog.json" with { type: "json" };
|
||||
import type { CatalogManifest, CatalogSkill } from "./types.js";
|
||||
|
||||
export type {
|
||||
CatalogCompatibility,
|
||||
CatalogManifest,
|
||||
CatalogSkill,
|
||||
CatalogSkillFile,
|
||||
CatalogSkillFileKind,
|
||||
CatalogSkillKind,
|
||||
CatalogTrustLevel,
|
||||
CatalogValidationResult,
|
||||
} from "./types.js";
|
||||
|
||||
export const catalogManifest = catalogManifestJson as CatalogManifest;
|
||||
|
||||
export const catalogSkills: CatalogSkill[] = catalogManifest.skills;
|
||||
|
||||
const skillsById = new Map(catalogSkills.map((skill) => [skill.id, skill]));
|
||||
const skillsByKey = new Map(catalogSkills.map((skill) => [skill.key, skill]));
|
||||
|
||||
export function getCatalogSkill(id: string): CatalogSkill | null {
|
||||
return skillsById.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function resolveCatalogSkillRef(ref: string): CatalogSkill | null {
|
||||
const normalized = ref.trim();
|
||||
if (normalized.length === 0) return null;
|
||||
|
||||
const exactMatch = skillsById.get(normalized) ?? skillsByKey.get(normalized);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
const slugMatches = catalogSkills.filter((skill) => skill.slug === normalized);
|
||||
if (slugMatches.length === 1) return slugMatches[0]!;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { catalogManifest, catalogSkills, resolveCatalogSkillRef } from "./index.js";
|
||||
import type { CatalogSkill } from "./types.js";
|
||||
|
||||
const EXPECTED_BUNDLED_KEYS = [
|
||||
"paperclipai/bundled/docs/doc-maintenance",
|
||||
"paperclipai/bundled/paperclip-operations/issue-triage",
|
||||
"paperclipai/bundled/paperclip-operations/task-planning",
|
||||
"paperclipai/bundled/quality/qa-acceptance",
|
||||
"paperclipai/bundled/software-development/github-pr-workflow",
|
||||
];
|
||||
|
||||
const EXPECTED_OPTIONAL_KEYS = [
|
||||
"paperclipai/optional/browser/agent-browser",
|
||||
"paperclipai/optional/content/release-announcement",
|
||||
"paperclipai/optional/product/design-critique",
|
||||
];
|
||||
|
||||
describe("shipped skills catalog", () => {
|
||||
it("ships the expected bundled and optional skill set", () => {
|
||||
const bundledKeys = catalogSkills
|
||||
.filter((skill) => skill.kind === "bundled")
|
||||
.map((skill) => skill.key)
|
||||
.sort();
|
||||
const optionalKeys = catalogSkills
|
||||
.filter((skill) => skill.kind === "optional")
|
||||
.map((skill) => skill.key)
|
||||
.sort();
|
||||
|
||||
expect(bundledKeys).toEqual(EXPECTED_BUNDLED_KEYS);
|
||||
expect(optionalKeys).toEqual(EXPECTED_OPTIONAL_KEYS);
|
||||
});
|
||||
|
||||
it("keeps every shipped skill markdown-only until a script-bearing skill clears security review", () => {
|
||||
const scriptBearing = catalogSkills.filter((skill) => skill.trustLevel !== "markdown_only");
|
||||
expect(scriptBearing, formatViolations("script-bearing skills require security review", scriptBearing)).toEqual([]);
|
||||
});
|
||||
|
||||
it("populates browse/search-relevant fields for every shipped skill", () => {
|
||||
const issues: string[] = [];
|
||||
for (const skill of catalogSkills) {
|
||||
if (skill.compatibility !== "compatible") {
|
||||
issues.push(`${skill.key} compatibility=${skill.compatibility}`);
|
||||
}
|
||||
if (!skill.description || skill.description.length < 40) {
|
||||
issues.push(`${skill.key} description must be at least 40 characters for catalog browse/search`);
|
||||
}
|
||||
if (skill.recommendedForRoles.length === 0) {
|
||||
issues.push(`${skill.key} must list recommendedForRoles`);
|
||||
}
|
||||
if (skill.tags.length === 0) {
|
||||
issues.push(`${skill.key} must list tags`);
|
||||
}
|
||||
}
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses canonical paperclipai keys derived from kind/category/slug", () => {
|
||||
const violations: string[] = [];
|
||||
for (const skill of catalogSkills) {
|
||||
const expectedKey = `paperclipai/${skill.kind}/${skill.category}/${skill.slug}`;
|
||||
const expectedId = `paperclipai:${skill.kind}:${skill.category}:${skill.slug}`;
|
||||
if (skill.key !== expectedKey) violations.push(`${skill.key} should be ${expectedKey}`);
|
||||
if (skill.id !== expectedId) violations.push(`${skill.id} should be ${expectedId}`);
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes a stable manifest header for downstream consumers", () => {
|
||||
expect(catalogManifest.schemaVersion).toBe(1);
|
||||
expect(catalogManifest.packageName).toBe("@paperclipai/skills-catalog");
|
||||
expect(catalogSkills.length).toBe(EXPECTED_BUNDLED_KEYS.length + EXPECTED_OPTIONAL_KEYS.length);
|
||||
});
|
||||
|
||||
it("resolves shipped skills by id, key, and unique slug", () => {
|
||||
const sample = catalogSkills.find((skill) => skill.key === "paperclipai/bundled/software-development/github-pr-workflow");
|
||||
expect(sample, "expected github-pr-workflow to ship in the bundled catalog").toBeDefined();
|
||||
if (!sample) return;
|
||||
|
||||
expect(resolveCatalogSkillRef(sample.id)).toMatchObject({ key: sample.key });
|
||||
expect(resolveCatalogSkillRef(sample.key)).toMatchObject({ key: sample.key });
|
||||
expect(resolveCatalogSkillRef(sample.slug)).toMatchObject({ key: sample.key });
|
||||
});
|
||||
});
|
||||
|
||||
function formatViolations(label: string, skills: CatalogSkill[]) {
|
||||
if (skills.length === 0) return label;
|
||||
const detail = skills.map((skill) => `${skill.key} (${skill.trustLevel})`).join(", ");
|
||||
return `${label}: ${detail}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type CatalogSkillKind = "bundled" | "optional";
|
||||
|
||||
export type CatalogTrustLevel = "markdown_only" | "assets" | "scripts_executables";
|
||||
|
||||
export type CatalogCompatibility = "compatible" | "unknown" | "invalid";
|
||||
|
||||
export type CatalogSkillFileKind = "skill" | "markdown" | "reference" | "script" | "asset" | "other";
|
||||
|
||||
export interface CatalogSkillFile {
|
||||
path: string;
|
||||
kind: CatalogSkillFileKind;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface CatalogSkill {
|
||||
id: string;
|
||||
key: string;
|
||||
kind: CatalogSkillKind;
|
||||
category: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
entrypoint: "SKILL.md";
|
||||
trustLevel: CatalogTrustLevel;
|
||||
compatibility: CatalogCompatibility;
|
||||
defaultInstall: boolean;
|
||||
recommendedForRoles: string[];
|
||||
requires: string[];
|
||||
tags: string[];
|
||||
files: CatalogSkillFile[];
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
export interface CatalogManifest {
|
||||
schemaVersion: 1;
|
||||
packageName: "@paperclipai/skills-catalog";
|
||||
packageVersion: string;
|
||||
generatedAt: string;
|
||||
skills: CatalogSkill[];
|
||||
}
|
||||
|
||||
export interface CatalogValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
manifest: CatalogManifest;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["generated/**/*.json", "scripts/**/*.ts", "src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+543
@@ -622,6 +622,8 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/skills-catalog: {}
|
||||
|
||||
server:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
@@ -702,9 +704,15 @@ importers:
|
||||
hermes-paperclip-adapter:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
isomorphic-git:
|
||||
specifier: ^1.38.0
|
||||
version: 1.38.3
|
||||
jsdom:
|
||||
specifier: ^28.1.0
|
||||
version: 28.1.0(@noble/hashes@2.0.1)
|
||||
memfs:
|
||||
specifier: ^4.57.2
|
||||
version: 4.57.3(tslib@2.8.1)
|
||||
multer:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -2315,6 +2323,126 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@jsonjoy.com/base64@1.1.2':
|
||||
resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/base64@17.67.0':
|
||||
resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/buffers@1.2.1':
|
||||
resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/buffers@17.67.0':
|
||||
resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/codegen@1.0.0':
|
||||
resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/codegen@17.67.0':
|
||||
resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-core@4.57.3':
|
||||
resolution: {integrity: sha512-IvO50vkGydDZwS1e9rz/JXEtCCt9XvqxoGI6FlrVIvVm4/HpygMKW4ETtREWtMTsN5CLJ9FR6GuCduoQPZLBiw==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-fsa@4.57.3':
|
||||
resolution: {integrity: sha512-JlIDGUWPl7Y6zl+/ISnZuh8z2aMr/xoR66D18zlaVAuL192CvlNJEzOlzp27x4P52HRtDnCSOk6f59vTsmp5vw==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node-builtins@4.57.3':
|
||||
resolution: {integrity: sha512-JAI3PqNuY8BR7ovy4h0bADLrqJLIcUauONNZfyTxUnj3Wf3tpTYe39eJ6z7FzYyA+tdMt33VpiQQUikGr3QOBw==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node-to-fsa@4.57.3':
|
||||
resolution: {integrity: sha512-uZGxyC0zDmcmW5bfHd4YivAZ54BLlbF9G0K5rBaksI/tZdJSGM7/AC+1TY7yvFu0Wc6gUHR7mFwf6SbQ3J1BTQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node-utils@4.57.3':
|
||||
resolution: {integrity: sha512-quCil8AvfcOxob4pn0drGdcQWpkPVgkt9q1+EjeyXXT40/L3l5lvYrr6hR8LmHu0eg+DNNaUwqjLT6Hr7V4sdQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node@4.57.3':
|
||||
resolution: {integrity: sha512-089gZoKvbeOsT2jeBaVKSz91oFXQWFG7a62sMY6gVMHnoWbyGzTb6OVUP/V7G3wLQLJ555BEsHt8SD1nj1dgaQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-print@4.57.3':
|
||||
resolution: {integrity: sha512-ITwaLZpGIqD9jHndwMvDFZDIvbVzGRsJZDQ5HKln0vyMculu1c1nb7zbEBgY8BVSBZ9S2xO138OWIBGeRsrF3Q==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-snapshot@4.57.3':
|
||||
resolution: {integrity: sha512-wdNaG2DxCtvj9lKldAnEV3ycYPEpk+p2cP2lHD1qdxkoQGlWUtQverqvG9KZSkm6BHFha4PP6XRZbpARNfHRxA==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/json-pack@1.21.0':
|
||||
resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/json-pack@17.67.0':
|
||||
resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/json-pointer@1.0.2':
|
||||
resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/json-pointer@17.67.0':
|
||||
resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/util@1.9.0':
|
||||
resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/util@17.67.0':
|
||||
resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@lexical/clipboard@0.35.0':
|
||||
resolution: {integrity: sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==}
|
||||
|
||||
@@ -4170,6 +4298,10 @@ packages:
|
||||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4277,6 +4409,9 @@ packages:
|
||||
resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
async-lock@1.4.1:
|
||||
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -4284,6 +4419,10 @@ packages:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axe-core@4.11.3:
|
||||
resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4485,6 +4624,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bind@1.0.9:
|
||||
resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4540,6 +4683,9 @@ packages:
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
clean-git-ref@2.0.1:
|
||||
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
|
||||
|
||||
clean-set@1.1.2:
|
||||
resolution: {integrity: sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==}
|
||||
|
||||
@@ -4653,6 +4799,11 @@ packages:
|
||||
cose-base@2.2.0:
|
||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
@@ -4893,6 +5044,10 @@ packages:
|
||||
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4936,6 +5091,9 @@ packages:
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
diff3@0.0.3:
|
||||
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
|
||||
|
||||
diff@5.2.2:
|
||||
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -5209,9 +5367,17 @@ packages:
|
||||
event-emitter@0.3.5:
|
||||
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
events-universal@1.0.1:
|
||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
eventsource-parser@3.0.6:
|
||||
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -5291,6 +5457,10 @@ packages:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
for-each@0.3.5:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -5365,6 +5535,12 @@ packages:
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
glob-to-regex.js@1.2.0:
|
||||
resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
glob@13.0.6:
|
||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -5383,6 +5559,9 @@ packages:
|
||||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
has-property-descriptors@1.0.2:
|
||||
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5457,6 +5636,10 @@ packages:
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
hyperdyperid@1.2.0:
|
||||
resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
|
||||
engines: {node: '>=10.18'}
|
||||
|
||||
i18next@26.2.0:
|
||||
resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==}
|
||||
peerDependencies:
|
||||
@@ -5476,6 +5659,10 @@ packages:
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
@@ -5529,6 +5716,10 @@ packages:
|
||||
is-alphanumerical@2.0.1:
|
||||
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
|
||||
|
||||
is-callable@1.2.7:
|
||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5573,13 +5764,25 @@ packages:
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
is-typed-array@1.1.15:
|
||||
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isomorphic-git@1.38.3:
|
||||
resolution: {integrity: sha512-rOpt0yIW9HD1o6mA4w2f4XP5Lj42QnccbFbhzkby6L+t9c2klQxf9seqyrw5x8JcWWnbuPuN7v7YJ7yvHj9OPA==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
isomorphic.js@0.2.5:
|
||||
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||
|
||||
@@ -5859,6 +6062,11 @@ packages:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
memfs@4.57.3:
|
||||
resolution: {integrity: sha512-dlvqataP1zUOlfj6pv9wgCSC5pRIooNntXgdLfR7FWlcKi1p8fMfJADtHp/+8Dhu5JFvMHNh7L0QVcuaaBKqqA==}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
merge-descriptors@2.0.0:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6023,6 +6231,9 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minimisted@2.0.1:
|
||||
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
|
||||
|
||||
minipass-collect@1.0.2:
|
||||
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -6177,6 +6388,9 @@ packages:
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -6259,6 +6473,10 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pify@4.0.1:
|
||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
@@ -6302,6 +6520,10 @@ packages:
|
||||
points-on-path@0.2.1:
|
||||
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -6351,6 +6573,10 @@ packages:
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
promise-inflight@1.0.1:
|
||||
resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
|
||||
peerDependencies:
|
||||
@@ -6534,6 +6760,10 @@ packages:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
@@ -6664,9 +6894,18 @@ packages:
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
sha.js@2.4.12:
|
||||
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
|
||||
engines: {node: '>= 0.10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -6891,6 +7130,12 @@ packages:
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
thingies@2.6.0:
|
||||
resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==}
|
||||
engines: {node: '>=10.18'}
|
||||
peerDependencies:
|
||||
tslib: ^2
|
||||
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
@@ -6930,6 +7175,10 @@ packages:
|
||||
resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==}
|
||||
hasBin: true
|
||||
|
||||
to-buffer@1.2.2:
|
||||
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -6942,6 +7191,12 @@ packages:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tree-dump@1.1.0:
|
||||
resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
|
||||
@@ -6981,6 +7236,10 @@ packages:
|
||||
type@2.7.3:
|
||||
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
@@ -7291,6 +7550,10 @@ packages:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
which-typed-array@1.1.21:
|
||||
resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -9027,6 +9290,133 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jsonjoy.com/base64@1.1.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/base64@17.67.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-core@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-fsa@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-core': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node-builtins@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node-to-fsa@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-fsa': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node-utils@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-core': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-print': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-snapshot': 4.57.3(tslib@2.8.1)
|
||||
glob-to-regex.js: 1.2.0(tslib@2.8.1)
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-print@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
|
||||
tree-dump: 1.1.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-snapshot@4.57.3(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/util': 17.67.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/base64': 1.1.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1)
|
||||
'@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/util': 1.9.0(tslib@2.8.1)
|
||||
hyperdyperid: 1.2.0
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tree-dump: 1.1.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/base64': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/util': 17.67.0(tslib@2.8.1)
|
||||
hyperdyperid: 1.2.0
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tree-dump: 1.1.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/util': 1.9.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/util': 17.67.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/util@1.9.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1)
|
||||
'@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/util@17.67.0(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@lexical/clipboard@0.35.0':
|
||||
dependencies:
|
||||
'@lexical/html': 0.35.0
|
||||
@@ -11264,6 +11654,10 @@ snapshots:
|
||||
abbrev@1.1.1:
|
||||
optional: true
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -11369,10 +11763,16 @@ snapshots:
|
||||
|
||||
async-exit-hook@2.0.1: {}
|
||||
|
||||
async-lock@1.4.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
axe-core@4.11.3: {}
|
||||
|
||||
b4a@1.8.1: {}
|
||||
@@ -11552,6 +11952,13 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bind@1.0.9:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
get-intrinsic: 1.3.0
|
||||
set-function-length: 1.2.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -11607,6 +12014,8 @@ snapshots:
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
clean-git-ref@2.0.1: {}
|
||||
|
||||
clean-set@1.1.2: {}
|
||||
|
||||
clean-stack@2.2.0:
|
||||
@@ -11708,6 +12117,8 @@ snapshots:
|
||||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-env@10.1.0:
|
||||
@@ -11966,6 +12377,12 @@ snapshots:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
|
||||
define-data-property@1.1.4:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
defu@6.1.4: {}
|
||||
@@ -12000,6 +12417,8 @@ snapshots:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
diff3@0.0.3: {}
|
||||
|
||||
diff@5.2.2: {}
|
||||
|
||||
diff@8.0.3: {}
|
||||
@@ -12265,12 +12684,16 @@ snapshots:
|
||||
d: 1.0.2
|
||||
es5-ext: 0.10.64
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
events-universal@1.0.1:
|
||||
dependencies:
|
||||
bare-events: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
eventsource-parser@3.0.6: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
@@ -12370,6 +12793,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
@@ -12449,6 +12876,10 @@ snapshots:
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
glob-to-regex.js@1.2.0(tslib@2.8.1):
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
glob@13.0.6:
|
||||
dependencies:
|
||||
minimatch: 10.2.5
|
||||
@@ -12471,6 +12902,10 @@ snapshots:
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
has-property-descriptors@1.0.2:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
@@ -12592,6 +13027,8 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
optional: true
|
||||
|
||||
hyperdyperid@1.2.0: {}
|
||||
|
||||
i18next@26.2.0(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -12606,6 +13043,8 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
optional: true
|
||||
|
||||
@@ -12646,6 +13085,8 @@ snapshots:
|
||||
is-alphabetical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
|
||||
is-callable@1.2.7: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
@@ -12676,12 +13117,32 @@ snapshots:
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-typed-array@1.1.15:
|
||||
dependencies:
|
||||
which-typed-array: 1.1.21
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic-git@1.38.3:
|
||||
dependencies:
|
||||
async-lock: 1.4.1
|
||||
clean-git-ref: 2.0.1
|
||||
crc-32: 1.2.2
|
||||
diff3: 0.0.3
|
||||
ignore: 5.3.2
|
||||
minimisted: 2.0.1
|
||||
pako: 1.0.11
|
||||
pify: 4.0.1
|
||||
readable-stream: 4.7.0
|
||||
sha.js: 2.4.12
|
||||
simple-get: 4.0.1
|
||||
|
||||
isomorphic.js@0.2.5: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
@@ -13075,6 +13536,23 @@ snapshots:
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
memfs@4.57.3(tslib@2.8.1):
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-core': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-fsa': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-to-fsa': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-print': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-snapshot': 4.57.3(tslib@2.8.1)
|
||||
'@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/util': 1.9.0(tslib@2.8.1)
|
||||
glob-to-regex.js: 1.2.0(tslib@2.8.1)
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tree-dump: 1.1.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
||||
mermaid@11.12.3:
|
||||
@@ -13421,6 +13899,10 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minimisted@2.0.1:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
minipass-collect@1.0.2:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
@@ -13585,6 +14067,8 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -13664,6 +14148,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pify@4.0.1: {}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
@@ -13734,6 +14220,8 @@ snapshots:
|
||||
path-data-parser: 0.1.0
|
||||
points-on-curve: 0.2.0
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
@@ -13784,6 +14272,8 @@ snapshots:
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
promise-inflight@1.0.1:
|
||||
optional: true
|
||||
|
||||
@@ -14028,6 +14518,14 @@ snapshots:
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
@@ -14215,8 +14713,23 @@ snapshots:
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
get-intrinsic: 1.3.0
|
||||
gopd: 1.2.0
|
||||
has-property-descriptors: 1.0.2
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sha.js@2.4.12:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
safe-buffer: 5.2.1
|
||||
to-buffer: 1.2.2
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
@@ -14550,6 +15063,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
thingies@2.6.0(tslib@2.8.1):
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
thread-stream@3.1.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
@@ -14579,6 +15096,12 @@ snapshots:
|
||||
dependencies:
|
||||
tldts-core: 7.0.26
|
||||
|
||||
to-buffer@1.2.2:
|
||||
dependencies:
|
||||
isarray: 2.0.5
|
||||
safe-buffer: 5.2.1
|
||||
typed-array-buffer: 1.0.3
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
@@ -14589,6 +15112,10 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tree-dump@1.1.0(tslib@2.8.1):
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
|
||||
trough@2.2.0: {}
|
||||
@@ -14629,6 +15156,12 @@ snapshots:
|
||||
|
||||
type@2.7.3: {}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
is-typed-array: 1.1.15
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
@@ -15005,6 +15538,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
which-typed-array@1.1.21:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
call-bind: 1.0.9
|
||||
call-bound: 1.0.4
|
||||
for-each: 0.3.5
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
@@ -104,6 +104,11 @@
|
||||
"name": "@paperclipai/plugin-daytona",
|
||||
"publishFromCi": true
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/kubernetes",
|
||||
"name": "@paperclipai/plugin-kubernetes",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/exe-dev",
|
||||
"name": "@paperclipai/plugin-exe-dev",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user