Compare commits
60 Commits
dev-gitea-skills
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 329ba3fd2e | |||
| bf251188df | |||
| 80f7d8270c | |||
| 4317d2a3b4 | |||
| d30afdb1b2 | |||
| 0fd4e9c4d1 | |||
| 818a8eade8 | |||
| 26e814a426 | |||
| 729ef021e9 | |||
| 9b275c332a | |||
| 9035b70aa9 | |||
| 1eccb71213 | |||
| f8b8303089 | |||
| 3e998bda97 | |||
| 40e8638aa3 | |||
| 713fb6eb4e | |||
| 58d1b19206 | |||
| fcbbd50b60 | |||
| a6c2e0392b | |||
| a98c5cdfa9 | |||
| 94fc81266f | |||
| b248acd46c | |||
| c37e5919ce | |||
| 45621aac53 | |||
| 39d81c732c | |||
| e691d30d12 | |||
| 163e3ca1a5 | |||
| 55d6c5bfa4 | |||
| b6b81f2f06 | |||
| 4c4eeaba2b | |||
| b61455373c | |||
| 73f4685729 | |||
| 7cee02ddf3 | |||
| 417782a6ec | |||
| 872dd664ed | |||
| d9d9bbcf06 | |||
| c2a49879d5 | |||
| 5d0d076704 | |||
| 08dc3d9ff4 | |||
| b3e7dcaa83 | |||
| 3ea3020a76 | |||
| 27db0d3c67 | |||
| 5c681340f3 | |||
| 5f3cd831a1 | |||
| 6cb333b986 | |||
| 18f550b946 | |||
| 191491a57f | |||
| 7e2517935c | |||
| 441bbd5b9a | |||
| bf1abb1492 | |||
| 1678160c49 | |||
| c08c72e917 | |||
| fe43fbe2fd | |||
| 6bbe51ca4d | |||
| 2131ede7b8 | |||
| e8579d5c66 | |||
| 9e30b72b27 | |||
| 7b12d907cc | |||
| d1d592d793 | |||
| 3dfb859676 |
+94
@@ -0,0 +1,94 @@
|
||||
name: "Build: Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: runners-farhoodlabs
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
image-tag: ${{ steps.tag.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image tag
|
||||
id: tag
|
||||
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/farhoodlabs/paperclip-dev
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: .farhoodlabs/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
|
||||
update-infra:
|
||||
needs: build
|
||||
runs-on: runners-farhoodlabs
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.PAPERCLIP_APP_ID }}
|
||||
private-key: ${{ secrets.PAPERCLIP_APP_PRIVATE_KEY }}
|
||||
repositories: paperclip-infra
|
||||
|
||||
- name: Update dev image tag in infra repo
|
||||
run: |
|
||||
SHA="${{ needs.build.outputs.image-tag }}"
|
||||
FILE="overlays/dev/kustomization.yaml"
|
||||
|
||||
response=$(curl -sS \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE")
|
||||
|
||||
file_sha=$(echo "$response" | jq -r '.sha')
|
||||
content=$(echo "$response" | jq -r '.content' | base64 -d)
|
||||
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
|
||||
encoded=$(printf '%s' "$new_content" | base64 -w 0)
|
||||
|
||||
curl -sS -X PUT \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
||||
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
name: "Build: Production"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [local]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: runners-farhoodlabs
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/farhoodlabs/paperclip
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
@@ -0,0 +1,79 @@
|
||||
# Paperclip Fork — Project Context
|
||||
|
||||
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
||||
Fork repo: https://github.com/farhoodlabs/paperclip
|
||||
|
||||
## Branch Model
|
||||
|
||||
| Branch | Purpose |
|
||||
|---|---|
|
||||
| `master` | Mirrors `upstream/master` exactly + `.farhoodlabs/` overlay directory + `assemble-local.yml` action. Never commit application code here. |
|
||||
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `ghcr.io/farhoodlabs/paperclip`. |
|
||||
| `dev` | Development branch based on upstream/master. Builds `ghcr.io/farhoodlabs/paperclip-dev` on every push. |
|
||||
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
|
||||
|
||||
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
|
||||
|
||||
## Fork Overlay (`.farhoodlabs/`)
|
||||
|
||||
Files committed to `master` that get copied into position on `local` by the assemble action:
|
||||
|
||||
```
|
||||
.farhoodlabs/
|
||||
CLAUDE.md → CLAUDE.md (repo root)
|
||||
Dockerfile → Dockerfile
|
||||
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
|
||||
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
|
||||
```
|
||||
|
||||
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
|
||||
|
||||
To modify fork-specific files, edit them in `.farhoodlabs/` on `master` and push — the assemble action will apply them to `local` automatically.
|
||||
|
||||
## Pending Upstream PRs (included in `local`)
|
||||
|
||||
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
|
||||
|
||||
| PR | Branch | Method | Notes |
|
||||
|---|---|---|---|
|
||||
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
|
||||
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
|
||||
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Sync upstream into master
|
||||
```bash
|
||||
git fetch upstream
|
||||
git push origin upstream/master:master --force-with-lease
|
||||
# assemble-local.yml triggers automatically and rebuilds local
|
||||
```
|
||||
|
||||
### Add a new pending PR to local
|
||||
Edit `.github/workflows/assemble-local.yml` on `master`:
|
||||
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
|
||||
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
|
||||
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
|
||||
|
||||
### Remove a PR after upstream merges it
|
||||
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `master`.
|
||||
|
||||
### Submit a new PR to upstream
|
||||
Branch from `upstream/master` (not from `local` or `master`):
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout -b feat/my-feature upstream/master
|
||||
```
|
||||
|
||||
### Modify the fork Dockerfile
|
||||
Edit `.farhoodlabs/Dockerfile` on `master`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
|
||||
|
||||
## Deployment
|
||||
|
||||
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `ghcr.io/farhoodlabs/paperclip:latest`.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
|
||||
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `master`
|
||||
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
|
||||
@@ -0,0 +1,101 @@
|
||||
# syntax=docker/dockerfile:1.20
|
||||
FROM node:lts-trixie-slim AS base
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
|
||||
# Modify the existing node user/group to have the specified UID/GID to match host user
|
||||
RUN usermod -u $USER_UID --non-unique node \
|
||||
&& groupmod -g $USER_GID --non-unique node \
|
||||
&& usermod -g $USER_GID -d /paperclip node
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
|
||||
COPY cli/package.json cli/
|
||||
COPY server/package.json server/
|
||||
COPY ui/package.json ui/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
|
||||
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
|
||||
COPY patches/ patches/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm --filter @paperclipai/ui build
|
||||
RUN pnpm --filter @paperclipai/plugin-sdk build
|
||||
RUN pnpm --filter @paperclipai/server build
|
||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
||||
|
||||
FROM base AS production
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools
|
||||
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
|
||||
&& chmod +x /usr/local/bin/kubectl \
|
||||
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
|
||||
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
|
||||
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& mv /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& curl -fsSL https://codeberg.org/forgejo-contrib/forgejo-cli/releases/download/v0.4.1/forgejo-cli-linux.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/fj \
|
||||
&& curl -fsSL https://github.com/JKamsker/forgejo-cli-ex/releases/download/v0.1.7/fj-ex-linux-x86_64.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||
&& chmod +x /usr/local/bin/fj-ex \
|
||||
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
|
||||
&& chmod +x /usr/local/bin/fgj \
|
||||
&& curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \
|
||||
&& chmod +x /usr/local/bin/tea \
|
||||
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOME=/paperclip \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3100 \
|
||||
SERVE_UI=true \
|
||||
PAPERCLIP_HOME=/paperclip \
|
||||
PAPERCLIP_INSTANCE_ID=default \
|
||||
USER_UID=${USER_UID} \
|
||||
USER_GID=${USER_GID} \
|
||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
|
||||
OPENCODE_ALLOW_ALL_MODELS=true
|
||||
|
||||
VOLUME ["/paperclip"]
|
||||
EXPOSE 3100
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
|
||||
@@ -0,0 +1,193 @@
|
||||
name: Assemble local branch
|
||||
|
||||
# Triggers on every master push (i.e. after syncing upstream) and on demand.
|
||||
# Builds the `local` branch: master + fork overlay + cherry-picked pending upstream PRs.
|
||||
# Syncs build-dev.yml to the `dev` branch so every dev push triggers a build.
|
||||
#
|
||||
# PR entries support an optional "exclude:BRANCH" suffix to handle cases where
|
||||
# one PR branch was rebased onto another. The exclude branch's commits are subtracted
|
||||
# from the cherry-pick range so they aren't double-applied.
|
||||
#
|
||||
# When upstream merges a PR, remove its entry from PR_CHERRY_PICK or PR_SQUASH below.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
assemble:
|
||||
runs-on: runners-farhoodlabs
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Fetch all remotes
|
||||
run: |
|
||||
git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true
|
||||
git fetch --all --quiet
|
||||
|
||||
- name: Assemble local branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start local from master (which mirrors upstream)
|
||||
git checkout -B local origin/master
|
||||
|
||||
# Apply fork overlay: Dockerfile, build workflows, CLAUDE.md
|
||||
cp .farhoodlabs/Dockerfile Dockerfile
|
||||
cp .farhoodlabs/CLAUDE.md CLAUDE.md
|
||||
mkdir -p .github/workflows
|
||||
cp .farhoodlabs/.github/workflows/build-prod.yml .github/workflows/build-prod.yml
|
||||
cp .farhoodlabs/.github/workflows/build-dev.yml .github/workflows/build-dev.yml
|
||||
git add Dockerfile CLAUDE.md .github/workflows/build-prod.yml .github/workflows/build-dev.yml
|
||||
git commit -m "chore: apply fork overlay from .farhoodlabs"
|
||||
|
||||
# --- PRs to cherry-pick commit-by-commit (clean, no merge commits) ---
|
||||
# Format: "PR-number branch-name [exclude:base-branch]"
|
||||
# Use exclude: when a branch was rebased onto another PR branch to avoid double-applying commits.
|
||||
# Remove an entry here when upstream merges the PR.
|
||||
PR_CHERRY_PICK=(
|
||||
"3237 skill-pat-feature"
|
||||
"3351 skill-scan-refresh exclude:skill-pat-feature"
|
||||
"4162 fix/far-108-k8s-adapter-reaper-liveness"
|
||||
)
|
||||
|
||||
for entry in "${PR_CHERRY_PICK[@]}"; do
|
||||
# Parse: pr_num, branch, optional exclude branch
|
||||
pr_num=$(echo "$entry" | awk '{print $1}')
|
||||
branch=$(echo "$entry" | awk '{print $2}')
|
||||
exclude_branch=$(echo "$entry" | grep -oP '(?<=exclude:)\S+' || true)
|
||||
remote_branch="origin/$branch"
|
||||
exclude_arg=""
|
||||
if [ -n "$exclude_branch" ]; then
|
||||
exclude_arg="--not origin/$exclude_branch"
|
||||
fi
|
||||
|
||||
if ! git rev-parse "$remote_branch" &>/dev/null; then
|
||||
echo "WARNING: $remote_branch not found, skipping PR #$pr_num"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Exclude commits already on origin/master (fork-overlay/CI infra
|
||||
# that landed on master via the .farhoodlabs/ overlay path). PR
|
||||
# branches sometimes pull these in via `git merge origin/master`,
|
||||
# but cherry-picking them onto `local` (which is already master)
|
||||
# is redundant and produces conflicts on the assemble-local file.
|
||||
mapfile -t commits < <(git log --no-merges --reverse --format="%H" upstream/master.."$remote_branch" ^origin/master $exclude_arg)
|
||||
|
||||
if [ ${#commits[@]} -eq 0 ]; then
|
||||
echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "PR #$pr_num ($branch): cherry-picking ${#commits[@]} commit(s)"
|
||||
for sha in "${commits[@]}"; do
|
||||
git cherry-pick "$sha" || {
|
||||
# If the cherry-pick produced an empty result (commit's content
|
||||
# is already in HEAD via auto-merge), skip it instead of failing.
|
||||
# State signature: CHERRY_PICK_HEAD set, no unmerged paths,
|
||||
# nothing staged.
|
||||
if [ -f .git/CHERRY_PICK_HEAD ] \
|
||||
&& [ -z "$(git diff --name-only --diff-filter=U)" ] \
|
||||
&& git diff --staged --quiet; then
|
||||
echo "PR #$pr_num: $sha became empty after merge, skipping"
|
||||
git cherry-pick --skip
|
||||
continue
|
||||
fi
|
||||
echo "::error::Cherry-pick conflict at $sha from PR #$pr_num ($branch)"
|
||||
echo "::error::Resolve the conflict, force-push the branch, then re-run this workflow"
|
||||
git cherry-pick --abort
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
done
|
||||
|
||||
# --- PRs to apply as a single squash (complex history with merge commits) ---
|
||||
# git merge --squash applies the net final diff of the branch, bypassing
|
||||
# intra-PR commit ordering issues. CI commits that cancel out are ignored.
|
||||
# Remove an entry here when upstream merges the PR.
|
||||
PR_SQUASH=(
|
||||
"3987 feat/company-portability-complete"
|
||||
)
|
||||
|
||||
for entry in "${PR_SQUASH[@]}"; do
|
||||
pr_num="${entry%% *}"
|
||||
branch="${entry#* }"
|
||||
remote_branch="origin/$branch"
|
||||
|
||||
if ! git rev-parse "$remote_branch" &>/dev/null; then
|
||||
echo "WARNING: $remote_branch not found, skipping PR #$pr_num"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if the branch has any unique non-merge commits
|
||||
unique=$(git log --no-merges --oneline upstream/master.."$remote_branch" | wc -l)
|
||||
if [ "$unique" -eq 0 ]; then
|
||||
echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "PR #$pr_num ($branch): applying as squash ($unique non-merge commits)"
|
||||
git merge --squash "$remote_branch" || {
|
||||
echo "::error::Squash conflict for PR #$pr_num ($branch)"
|
||||
git merge --abort 2>/dev/null || git reset --hard HEAD
|
||||
exit 1
|
||||
}
|
||||
# Only commit if there are staged changes
|
||||
git diff --staged --quiet || git commit -m "feat: apply PR #$pr_num ($branch)"
|
||||
done
|
||||
|
||||
git push origin local --force
|
||||
echo "local branch assembled and pushed"
|
||||
|
||||
- name: Trigger prod build
|
||||
run: |
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-prod.yml/dispatches \
|
||||
-d '{"ref":"local"}'
|
||||
|
||||
- name: Sync build-dev.yml to dev branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! git rev-parse origin/dev &>/dev/null; then
|
||||
echo "dev branch not found on origin, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
canonical=".farhoodlabs/.github/workflows/build-dev.yml"
|
||||
target=".github/workflows/build-dev.yml"
|
||||
|
||||
if git show origin/dev:"$target" 2>/dev/null | diff --brief - "$canonical" &>/dev/null; then
|
||||
echo "build-dev.yml on dev is up to date, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Syncing build-dev.yml to dev branch..."
|
||||
# Save canonical content before switching branches (.farhoodlabs/ only exists on master)
|
||||
tmp=$(mktemp)
|
||||
cp "$canonical" "$tmp"
|
||||
git checkout -B dev-wf-sync origin/dev
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp "$tmp" "$target"
|
||||
rm "$tmp"
|
||||
git add "$target"
|
||||
git commit -m "chore(ci): sync build-dev.yml from .farhoodlabs"
|
||||
git push origin dev-wf-sync:dev
|
||||
echo "build-dev.yml synced to dev"
|
||||
@@ -0,0 +1,94 @@
|
||||
name: "Build: Dev"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: runners-farhoodlabs
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
image-tag: ${{ steps.tag.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image tag
|
||||
id: tag
|
||||
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/farhoodlabs/paperclip-dev
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: .farhoodlabs/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
|
||||
update-infra:
|
||||
needs: build
|
||||
runs-on: runners-farhoodlabs
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.PAPERCLIP_APP_ID }}
|
||||
private-key: ${{ secrets.PAPERCLIP_APP_PRIVATE_KEY }}
|
||||
repositories: paperclip-infra
|
||||
|
||||
- name: Update dev image tag in infra repo
|
||||
run: |
|
||||
SHA="${{ needs.build.outputs.image-tag }}"
|
||||
FILE="overlays/dev/kustomization.yaml"
|
||||
|
||||
response=$(curl -sS \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE")
|
||||
|
||||
file_sha=$(echo "$response" | jq -r '.sha')
|
||||
content=$(echo "$response" | jq -r '.content' | base64 -d)
|
||||
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
|
||||
encoded=$(printf '%s' "$new_content" | base64 -w 0)
|
||||
|
||||
curl -sS -X PUT \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
||||
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
||||
@@ -0,0 +1,53 @@
|
||||
name: "Build: Production"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [local]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: runners-farhoodlabs
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/farhoodlabs/paperclip
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
@@ -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,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: runners-farhoodlabs
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Fetch upstream
|
||||
run: |
|
||||
git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true
|
||||
git fetch upstream
|
||||
|
||||
- name: Fast-forward master to upstream
|
||||
run: |
|
||||
git merge --ff-only upstream/master || {
|
||||
echo "::error::Cannot fast-forward master to upstream/master — diverged history"
|
||||
echo "::error::Resolve manually: git fetch upstream && git rebase upstream/master"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Re-apply fork workflow overrides
|
||||
run: |
|
||||
# These files are overridden in the fork to prevent upstream workflows from
|
||||
# running on fork pushes. Re-apply after each upstream sync.
|
||||
OVERRIDE_FILES=(
|
||||
".github/workflows/docker.yml"
|
||||
".github/workflows/release.yml"
|
||||
)
|
||||
changed=false
|
||||
for f in "${OVERRIDE_FILES[@]}"; do
|
||||
fork_version=$(git show origin/master:"$f" 2>/dev/null || true)
|
||||
current=$(cat "$f" 2>/dev/null || true)
|
||||
if [ "$fork_version" != "$current" ]; then
|
||||
echo "Re-applying fork override: $f"
|
||||
git checkout origin/master -- "$f"
|
||||
changed=true
|
||||
fi
|
||||
done
|
||||
if [ "$changed" = true ]; then
|
||||
git add "${OVERRIDE_FILES[@]}"
|
||||
git commit -m "chore(ci): re-apply fork workflow overrides after upstream sync"
|
||||
fi
|
||||
|
||||
- name: Push master
|
||||
run: git push origin master
|
||||
@@ -0,0 +1,79 @@
|
||||
# Paperclip Fork — Project Context
|
||||
|
||||
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
||||
Fork repo: https://github.com/farhoodlabs/paperclip
|
||||
|
||||
## Branch Model
|
||||
|
||||
| Branch | Purpose |
|
||||
|---|---|
|
||||
| `master` | Mirrors `upstream/master` exactly + `.farhoodlabs/` overlay directory + `assemble-local.yml` action. Never commit application code here. |
|
||||
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `ghcr.io/farhoodlabs/paperclip`. |
|
||||
| `dev` | Development branch based on upstream/master. Builds `ghcr.io/farhoodlabs/paperclip-dev` on every push. |
|
||||
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
|
||||
|
||||
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
|
||||
|
||||
## Fork Overlay (`.farhoodlabs/`)
|
||||
|
||||
Files committed to `master` that get copied into position on `local` by the assemble action:
|
||||
|
||||
```
|
||||
.farhoodlabs/
|
||||
CLAUDE.md → CLAUDE.md (repo root)
|
||||
Dockerfile → Dockerfile
|
||||
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
|
||||
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
|
||||
```
|
||||
|
||||
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
|
||||
|
||||
To modify fork-specific files, edit them in `.farhoodlabs/` on `master` and push — the assemble action will apply them to `local` automatically.
|
||||
|
||||
## Pending Upstream PRs (included in `local`)
|
||||
|
||||
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
|
||||
|
||||
| PR | Branch | Method | Notes |
|
||||
|---|---|---|---|
|
||||
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
|
||||
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
|
||||
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Sync upstream into master
|
||||
```bash
|
||||
git fetch upstream
|
||||
git push origin upstream/master:master --force-with-lease
|
||||
# assemble-local.yml triggers automatically and rebuilds local
|
||||
```
|
||||
|
||||
### Add a new pending PR to local
|
||||
Edit `.github/workflows/assemble-local.yml` on `master`:
|
||||
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
|
||||
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
|
||||
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
|
||||
|
||||
### Remove a PR after upstream merges it
|
||||
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `master`.
|
||||
|
||||
### Submit a new PR to upstream
|
||||
Branch from `upstream/master` (not from `local` or `master`):
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout -b feat/my-feature upstream/master
|
||||
```
|
||||
|
||||
### Modify the fork Dockerfile
|
||||
Edit `.farhoodlabs/Dockerfile` on `master`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
|
||||
|
||||
## Deployment
|
||||
|
||||
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `ghcr.io/farhoodlabs/paperclip:latest`.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
|
||||
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `master`
|
||||
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -379,6 +379,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;
|
||||
}
|
||||
@@ -386,6 +393,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 {
|
||||
|
||||
@@ -549,6 +549,7 @@ export type {
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
CompanyPortabilitySecretEntry,
|
||||
EnvBinding,
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
@@ -974,6 +975,7 @@ export {
|
||||
companySkillDetailSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillUpdateAuthSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillProjectScanSkippedSchema,
|
||||
companySkillProjectScanConflictSchema,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { AgentEnvConfig, SecretProvider } from "./secrets.js";
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ export type {
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
CompanyPortabilitySecretEntry,
|
||||
} from "./company-portability.js";
|
||||
export type {
|
||||
JsonSchema,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -68,6 +68,11 @@ export const companySkillUpdateStatusSchema = z.object({
|
||||
|
||||
export const companySkillImportSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
authToken: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const companySkillUpdateAuthSchema = z.object({
|
||||
authToken: z.string().min(1).nullable(),
|
||||
});
|
||||
|
||||
export const companySkillProjectScanRequestSchema = z.object({
|
||||
@@ -135,3 +140,4 @@ export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
|
||||
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
||||
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
||||
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
||||
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
|
||||
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
companySkillDetailSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillUpdateAuthSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillProjectScanSkippedSchema,
|
||||
companySkillProjectScanConflictSchema,
|
||||
@@ -74,6 +75,7 @@ export {
|
||||
type CompanySkillProjectScan,
|
||||
type CompanySkillCreate,
|
||||
type CompanySkillFileUpdate,
|
||||
type CompanySkillUpdateAuth,
|
||||
} from "./company-skill.js";
|
||||
export {
|
||||
agentSkillStateSchema,
|
||||
|
||||
Generated
+541
@@ -646,9 +646,15 @@ importers:
|
||||
hermes-paperclip-adapter:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
isomorphic-git:
|
||||
specifier: ^1.38.0
|
||||
version: 1.38.0
|
||||
jsdom:
|
||||
specifier: ^28.1.0
|
||||
version: 28.1.0(@noble/hashes@2.0.1)
|
||||
memfs:
|
||||
specifier: ^4.57.2
|
||||
version: 4.57.2(tslib@2.8.1)
|
||||
multer:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -2246,6 +2252,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.2':
|
||||
resolution: {integrity: sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-fsa@4.57.2':
|
||||
resolution: {integrity: sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node-builtins@4.57.2':
|
||||
resolution: {integrity: sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node-to-fsa@4.57.2':
|
||||
resolution: {integrity: sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node-utils@4.57.2':
|
||||
resolution: {integrity: sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-node@4.57.2':
|
||||
resolution: {integrity: sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-print@4.57.2':
|
||||
resolution: {integrity: sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==}
|
||||
engines: {node: '>=10.0'}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
'@jsonjoy.com/fs-snapshot@4.57.2':
|
||||
resolution: {integrity: sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==}
|
||||
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==}
|
||||
|
||||
@@ -4067,6 +4193,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'}
|
||||
@@ -4174,6 +4304,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==}
|
||||
|
||||
@@ -4181,6 +4314,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'}
|
||||
@@ -4382,6 +4519,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'}
|
||||
@@ -4437,6 +4578,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==}
|
||||
|
||||
@@ -4550,6 +4694,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==}
|
||||
|
||||
@@ -4790,6 +4939,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'}
|
||||
@@ -4833,6 +4986,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'}
|
||||
@@ -5102,9 +5258,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'}
|
||||
@@ -5184,6 +5348,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'}
|
||||
@@ -5258,6 +5426,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}
|
||||
@@ -5276,6 +5450,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'}
|
||||
@@ -5341,6 +5518,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'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5352,6 +5533,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'}
|
||||
@@ -5405,6 +5590,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'}
|
||||
@@ -5449,13 +5638,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.0:
|
||||
resolution: {integrity: sha512-gsBFnAT8Fxrpx+53ymG5kEOHSrUDVcSMFl7fCEGVnPpQbPS0aKti3UzZXR+3DKA0yyf+4z6CXJxULlQ5QPxDJw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
isomorphic.js@0.2.5:
|
||||
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||
|
||||
@@ -5732,6 +5933,11 @@ packages:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
memfs@4.57.2:
|
||||
resolution: {integrity: sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==}
|
||||
peerDependencies:
|
||||
tslib: '2'
|
||||
|
||||
merge-descriptors@2.0.0:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5896,6 +6102,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'}
|
||||
@@ -6044,6 +6253,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==}
|
||||
|
||||
@@ -6126,6 +6338,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==}
|
||||
|
||||
@@ -6169,6 +6385,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'}
|
||||
@@ -6218,6 +6438,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:
|
||||
@@ -6385,6 +6609,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'}
|
||||
@@ -6506,9 +6734,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}
|
||||
@@ -6730,6 +6967,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==}
|
||||
|
||||
@@ -6769,6 +7012,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'}
|
||||
@@ -6781,6 +7028,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==}
|
||||
|
||||
@@ -6820,6 +7073,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==}
|
||||
|
||||
@@ -7126,6 +7383,10 @@ packages:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -8860,6 +9121,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.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1)
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-fsa@4.57.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1)
|
||||
thingies: 2.6.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node-builtins@4.57.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node-to-fsa@4.57.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node-utils@4.57.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-node@4.57.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-snapshot': 4.57.2(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.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1)
|
||||
tree-dump: 1.1.0(tslib@2.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@jsonjoy.com/fs-snapshot@4.57.2(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.2(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
|
||||
@@ -11046,6 +11434,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
|
||||
@@ -11151,10 +11543,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: {}
|
||||
@@ -11334,6 +11732,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
|
||||
@@ -11389,6 +11794,8 @@ snapshots:
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
clean-git-ref@2.0.1: {}
|
||||
|
||||
clean-set@1.1.2: {}
|
||||
|
||||
clean-stack@2.2.0:
|
||||
@@ -11490,6 +11897,8 @@ snapshots:
|
||||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-env@10.1.0:
|
||||
@@ -11748,6 +12157,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: {}
|
||||
@@ -11782,6 +12197,8 @@ snapshots:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
diff3@0.0.3: {}
|
||||
|
||||
diff@5.2.2: {}
|
||||
|
||||
doctrine@3.0.0:
|
||||
@@ -12045,12 +12462,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:
|
||||
@@ -12150,6 +12571,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
|
||||
@@ -12229,6 +12654,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
|
||||
@@ -12251,6 +12680,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:
|
||||
@@ -12352,6 +12785,8 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
optional: true
|
||||
|
||||
hyperdyperid@1.2.0: {}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -12362,6 +12797,8 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
optional: true
|
||||
|
||||
@@ -12402,6 +12839,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
|
||||
@@ -12432,12 +12871,32 @@ snapshots:
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-typed-array@1.1.15:
|
||||
dependencies:
|
||||
which-typed-array: 1.1.20
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic-git@1.38.0:
|
||||
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: {}
|
||||
@@ -12829,6 +13288,23 @@ snapshots:
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
memfs@4.57.2(tslib@2.8.1):
|
||||
dependencies:
|
||||
'@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-to-fsa': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1)
|
||||
'@jsonjoy.com/fs-snapshot': 4.57.2(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:
|
||||
@@ -13175,6 +13651,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
|
||||
@@ -13331,6 +13811,8 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -13410,6 +13892,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pify@4.0.1: {}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
@@ -13480,6 +13964,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
|
||||
@@ -13530,6 +14016,8 @@ snapshots:
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
promise-inflight@1.0.1:
|
||||
optional: true
|
||||
|
||||
@@ -13763,6 +14251,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: {}
|
||||
@@ -13940,8 +14436,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
|
||||
@@ -14264,6 +14775,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
|
||||
@@ -14293,6 +14808,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:
|
||||
@@ -14303,6 +14824,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: {}
|
||||
@@ -14343,6 +14868,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: {}
|
||||
@@ -14717,6 +15248,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
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
|
||||
|
||||
@@ -94,6 +94,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",
|
||||
|
||||
@@ -68,7 +68,9 @@
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"express": "^5.1.0",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"isomorphic-git": "^1.38.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"memfs": "^4.57.2",
|
||||
"multer": "^2.1.1",
|
||||
"open": "^11.0.0",
|
||||
"pino": "^9.6.0",
|
||||
|
||||
@@ -196,6 +196,15 @@ describe("agent test-environment route", () => {
|
||||
}),
|
||||
status: "failed",
|
||||
});
|
||||
// Ad-hoc operator probes have no agent context — the route must pass
|
||||
// agentId: null so plugin-backed providers don't accidentally scope the
|
||||
// probe lease against some leftover agentId from the heartbeat path.
|
||||
expect(mockEnvironmentRuntime.acquireRunLease).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: null,
|
||||
heartbeatRunId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a diagnostic result instead of probing the host when the requested environment is missing", async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ const companySvc = {
|
||||
|
||||
const agentSvc = {
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
@@ -27,6 +28,7 @@ const accessSvc = {
|
||||
|
||||
const projectSvc = {
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
@@ -64,6 +66,26 @@ const assetSvc = {
|
||||
const secretSvc = {
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config, secretKeys: new Set<string>() })),
|
||||
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env as Record<string, unknown>),
|
||||
getById: vi.fn(async (id: string) => {
|
||||
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
|
||||
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
|
||||
return null;
|
||||
}),
|
||||
resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: "latest") => {
|
||||
if (secretId === "secret-1") return "sk-ant-secret-xxx";
|
||||
if (secretId === "secret-2") return "ghp_secretxxx";
|
||||
throw new Error("Secret not found");
|
||||
}),
|
||||
create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
|
||||
id: `new-secret-${input.name}`,
|
||||
companyId,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
description: input.description ?? null,
|
||||
latestVersion: 1,
|
||||
})),
|
||||
getByName: vi.fn(async (_companyId: string, name: string) => null),
|
||||
};
|
||||
|
||||
const agentInstructionsSvc = {
|
||||
@@ -115,6 +137,25 @@ vi.mock("../routes/org-chart-svg.js", () => ({
|
||||
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
|
||||
}));
|
||||
|
||||
const gitSourceMock = vi.hoisted(() => ({
|
||||
resolveGitRef: vi.fn(),
|
||||
openRepoSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
// parseGitSourceUrl stays real (the shim parseGitHubSourceUrl delegates to it
|
||||
// and is asserted by existing tests). Only the network-touching functions are
|
||||
// overridable per-test.
|
||||
vi.mock("../services/git-source.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/git-source.js")>(
|
||||
"../services/git-source.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveGitRef: gitSourceMock.resolveGitRef,
|
||||
openRepoSnapshot: gitSourceMock.openRepoSnapshot,
|
||||
};
|
||||
});
|
||||
|
||||
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
|
||||
|
||||
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
||||
@@ -458,7 +499,6 @@ describe("company portability", () => {
|
||||
expect(extension).not.toContain("instructionsFilePath");
|
||||
expect(extension).not.toContain("command:");
|
||||
expect(extension).not.toContain("secretId");
|
||||
expect(extension).not.toContain('type: "secret_ref"');
|
||||
expect(extension).toContain("inputs:");
|
||||
expect(extension).toContain("ANTHROPIC_API_KEY:");
|
||||
expect(extension).toContain('requirement: "optional"');
|
||||
@@ -1273,6 +1313,9 @@ describe("company portability", () => {
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
secretName: "anthropic-api-key",
|
||||
secretProvider: "local_encrypted",
|
||||
type: "secret_ref",
|
||||
},
|
||||
{
|
||||
key: "GH_TOKEN",
|
||||
@@ -1283,6 +1326,9 @@ describe("company portability", () => {
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
secretName: "gh-token",
|
||||
secretProvider: "local_encrypted",
|
||||
type: "secret_ref",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1406,6 +1452,9 @@ describe("company portability", () => {
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
secretName: null,
|
||||
secretProvider: null,
|
||||
type: "plain",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3013,6 +3062,191 @@ describe("company portability", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
describe("secret env vars", () => {
|
||||
beforeEach(() => {
|
||||
// Reset create/getByName to ensure clean state per test
|
||||
secretSvc.create.mockReset();
|
||||
secretSvc.getByName.mockReset();
|
||||
secretSvc.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" };
|
||||
if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" };
|
||||
return null;
|
||||
});
|
||||
secretSvc.resolveSecretValue.mockImplementation(async (_companyId: string, secretId: string) => {
|
||||
if (secretId === "secret-1") return "sk-ant-secret-xxx";
|
||||
if (secretId === "secret-2") return "ghp_secretxxx";
|
||||
throw new Error("Secret not found");
|
||||
});
|
||||
secretSvc.create.mockImplementation(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({
|
||||
id: `new-secret-${input.name}`,
|
||||
companyId,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
description: input.description ?? null,
|
||||
latestVersion: 1,
|
||||
}));
|
||||
secretSvc.getByName.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("exports secret env var metadata with secretName and secretProvider", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
agents: ["claudecoder"],
|
||||
});
|
||||
const secretInput = exported.manifest.envInputs.find(
|
||||
(e: any) => e.key === "ANTHROPIC_API_KEY" && e.kind === "secret",
|
||||
);
|
||||
expect(secretInput).toBeDefined();
|
||||
expect(secretInput.secretName).toBe("anthropic-api-key");
|
||||
expect(secretInput.secretProvider).toBe("local_encrypted");
|
||||
});
|
||||
|
||||
it("exports secret values to manifest when includeSecrets is true", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
agents: ["claudecoder"],
|
||||
includeSecrets: true,
|
||||
});
|
||||
expect(exported.manifest.secrets).toBeDefined();
|
||||
expect(exported.manifest.secrets).toContainEqual(expect.objectContaining({
|
||||
name: "anthropic-api-key",
|
||||
provider: "local_encrypted",
|
||||
currentValue: "sk-ant-secret-xxx",
|
||||
}));
|
||||
});
|
||||
|
||||
it("omits secrets section when includeSecrets is false", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
agents: ["claudecoder"],
|
||||
includeSecrets: false,
|
||||
});
|
||||
expect(exported.manifest.secrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes placeholder when resolveSecretValue throws (cross-instance decryption failure)", async () => {
|
||||
secretSvc.resolveSecretValue.mockImplementation(async () => {
|
||||
throw new Error("Decryption failed: missing master key");
|
||||
});
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
agents: ["claudecoder"],
|
||||
includeSecrets: true,
|
||||
});
|
||||
const secretEntry = exported.manifest.secrets?.find((s: any) => s.name === "anthropic-api-key");
|
||||
expect(secretEntry?.currentValue).toBe("<decryption-key-missing:anthropic-api-key>");
|
||||
expect(exported.warnings).toContainEqual(expect.stringContaining("could not be decrypted during export"));
|
||||
});
|
||||
|
||||
it("imports secrets and remaps secret_ref bindings to new secret IDs", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
|
||||
id: "new-agent-1",
|
||||
companyId,
|
||||
...patch,
|
||||
}));
|
||||
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
|
||||
agentSvc.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "new-agent-1") {
|
||||
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
agents: ["claudecoder"],
|
||||
includeSecrets: true,
|
||||
});
|
||||
const imported = await portability.importBundle({
|
||||
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
target: { mode: "existing_company", companyId: "company-imported" },
|
||||
agents: ["claudecoder"],
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
expect(secretSvc.create).toHaveBeenCalled();
|
||||
expect(agentSvc.update).toHaveBeenCalledWith(
|
||||
"new-agent-1",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses existing secret on conflict during import", async () => {
|
||||
secretSvc.getByName.mockImplementation(async (_companyId: string, name: string) => {
|
||||
if (name === "anthropic-api-key") return { id: "existing-secret-1", name, provider: "local_encrypted" };
|
||||
return null;
|
||||
});
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.create.mockImplementation(async (companyId: string, patch: Record<string, unknown>) => ({
|
||||
id: "new-agent-1",
|
||||
companyId,
|
||||
...patch,
|
||||
}));
|
||||
agentSvc.update.mockImplementation(async (id: string, patch: Record<string, unknown>) => patch as any);
|
||||
agentSvc.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "new-agent-1") {
|
||||
return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
agents: ["claudecoder"],
|
||||
includeSecrets: true,
|
||||
});
|
||||
await portability.importBundle({
|
||||
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
target: { mode: "existing_company", companyId: "company-imported" },
|
||||
agents: ["claudecoder"],
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
expect(agentSvc.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports plain env vars faithfully", async () => {
|
||||
agentSvc.list.mockResolvedValue([{
|
||||
id: "agent-1",
|
||||
name: "TestAgent",
|
||||
status: "idle",
|
||||
role: "agent",
|
||||
title: null,
|
||||
icon: null,
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
PLAIN_VAR: { type: "plain", value: "plain-value" },
|
||||
ANOTHER_VAR: { type: "plain", value: "another-value" },
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
}]);
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { agents: true, company: false, projects: false, issues: false, skills: false },
|
||||
agents: ["testagent"],
|
||||
});
|
||||
const plainInputs = exported.manifest.envInputs.filter((e: any) => e.kind === "plain");
|
||||
expect(plainInputs).toContainEqual(expect.objectContaining({
|
||||
key: "PLAIN_VAR",
|
||||
defaultValue: "plain-value",
|
||||
}));
|
||||
expect(plainInputs).toContainEqual(expect.objectContaining({
|
||||
key: "ANOTHER_VAR",
|
||||
defaultValue: "another-value",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
@@ -3163,3 +3397,173 @@ describe("company portability", () => {
|
||||
expect(preview.plan.issuePlans).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git source orchestration via resolveSource", () => {
|
||||
const minimalCompanyMarkdown = "---\ncompany:\n name: Demo\n---\n# Demo\n";
|
||||
const githubUrl = "https://git.example.com/acme/co?ref=main&path=";
|
||||
|
||||
function makeSnapshot(overrides: {
|
||||
files?: string[];
|
||||
fileContents?: Record<string, string>;
|
||||
binaryContents?: Record<string, Uint8Array>;
|
||||
readBinaryReject?: Error;
|
||||
} = {}) {
|
||||
const files = overrides.files ?? ["COMPANY.md"];
|
||||
const fileContents = overrides.fileContents ?? { "COMPANY.md": minimalCompanyMarkdown };
|
||||
const binaryContents = overrides.binaryContents ?? {};
|
||||
return {
|
||||
sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
listFiles: vi.fn(async () => files),
|
||||
readFile: vi.fn(async (p: string) => {
|
||||
if (p in fileContents) return fileContents[p];
|
||||
throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" });
|
||||
}),
|
||||
readFileOptional: vi.fn(async (p: string) => fileContents[p] ?? null),
|
||||
readBinary: vi.fn(async (p: string) => {
|
||||
if (overrides.readBinaryReject) throw overrides.readBinaryReject;
|
||||
if (p in binaryContents) return binaryContents[p]!;
|
||||
throw Object.assign(new Error(`not found: ${p}`), { code: "NotFoundError" });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function setupResolveStub() {
|
||||
gitSourceMock.resolveGitRef.mockResolvedValue({
|
||||
pinnedSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
trackingRef: "main",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gitSourceMock.resolveGitRef.mockReset();
|
||||
gitSourceMock.openRepoSnapshot.mockReset();
|
||||
companySvc.getById.mockResolvedValue(null);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
projectSvc.list.mockResolvedValue([]);
|
||||
issueSvc.list.mockResolvedValue([]);
|
||||
issueSvc.listComments.mockResolvedValue([]);
|
||||
companySkillSvc.list.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("opens a snapshot and walks the tree for a github source", async () => {
|
||||
setupResolveStub();
|
||||
const snapshot = makeSnapshot({
|
||||
files: ["COMPANY.md", "README.md", "skills/x/SKILL.md"],
|
||||
fileContents: {
|
||||
"COMPANY.md": minimalCompanyMarkdown,
|
||||
"README.md": "# readme",
|
||||
"skills/x/SKILL.md": "---\nname: x\n---\n",
|
||||
},
|
||||
});
|
||||
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
|
||||
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const preview = await portability.previewImport({
|
||||
source: { type: "github", url: githubUrl },
|
||||
include: { company: true, agents: false, projects: false, issues: false, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Demo" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(gitSourceMock.resolveGitRef).toHaveBeenCalledTimes(1);
|
||||
expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(snapshot.listFiles).toHaveBeenCalled();
|
||||
expect(snapshot.readFileOptional).toHaveBeenCalledWith("COMPANY.md");
|
||||
expect(snapshot.readFile).toHaveBeenCalledWith("README.md");
|
||||
expect(snapshot.readFile).toHaveBeenCalledWith("skills/x/SKILL.md");
|
||||
expect(preview.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back from main to master when the main ref does not exist", async () => {
|
||||
setupResolveStub();
|
||||
const masterSnap = makeSnapshot();
|
||||
// First call (ref=main) rejects; second (ref=master) succeeds.
|
||||
gitSourceMock.openRepoSnapshot
|
||||
.mockRejectedValueOnce(new Error("ref not found"))
|
||||
.mockResolvedValueOnce(masterSnap);
|
||||
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const preview = await portability.previewImport({
|
||||
source: { type: "github", url: githubUrl },
|
||||
include: { company: true, agents: false, projects: false, issues: false, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Demo" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(gitSourceMock.openRepoSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(masterSnap.readFileOptional).toHaveBeenCalledWith("COMPANY.md");
|
||||
expect(preview.warnings).toContain("Git ref main not found; falling back to master.");
|
||||
});
|
||||
|
||||
it("throws when COMPANY.md is missing on both main and master", async () => {
|
||||
setupResolveStub();
|
||||
const emptySnap = makeSnapshot({ fileContents: {} });
|
||||
gitSourceMock.openRepoSnapshot.mockResolvedValue(emptySnap);
|
||||
|
||||
const portability = companyPortabilityService({} as any);
|
||||
await expect(
|
||||
portability.previewImport({
|
||||
source: { type: "github", url: githubUrl },
|
||||
include: { company: true, agents: false, projects: false, issues: false, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Demo" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
}),
|
||||
).rejects.toThrow(/missing COMPANY.md/i);
|
||||
});
|
||||
|
||||
it("fetches a referenced company logo as binary", async () => {
|
||||
setupResolveStub();
|
||||
// logoPath lives in .paperclip.yaml (paperclip extension), not COMPANY.md.
|
||||
const paperclipYaml = "company:\n logoPath: images/logo.png\n";
|
||||
const logoBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const snapshot = makeSnapshot({
|
||||
files: ["COMPANY.md", ".paperclip.yaml", "images/logo.png"],
|
||||
fileContents: {
|
||||
"COMPANY.md": minimalCompanyMarkdown,
|
||||
".paperclip.yaml": paperclipYaml,
|
||||
},
|
||||
binaryContents: { "images/logo.png": logoBytes },
|
||||
});
|
||||
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
|
||||
|
||||
const portability = companyPortabilityService({} as any);
|
||||
await portability.previewImport({
|
||||
source: { type: "github", url: githubUrl },
|
||||
include: { company: true, agents: false, projects: false, issues: false, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Demo" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(snapshot.readBinary).toHaveBeenCalledWith("images/logo.png");
|
||||
});
|
||||
|
||||
it("warns instead of throwing when the logo blob can't be read", async () => {
|
||||
setupResolveStub();
|
||||
const paperclipYaml = "company:\n logoPath: images/logo.png\n";
|
||||
const snapshot = makeSnapshot({
|
||||
files: ["COMPANY.md", ".paperclip.yaml"],
|
||||
fileContents: {
|
||||
"COMPANY.md": minimalCompanyMarkdown,
|
||||
".paperclip.yaml": paperclipYaml,
|
||||
},
|
||||
readBinaryReject: new Error("blob missing"),
|
||||
});
|
||||
gitSourceMock.openRepoSnapshot.mockResolvedValue(snapshot);
|
||||
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const preview = await portability.previewImport({
|
||||
source: { type: "github", url: githubUrl },
|
||||
include: { company: true, agents: false, projects: false, issues: false, skills: false },
|
||||
target: { mode: "new_company", newCompanyName: "Demo" },
|
||||
agents: "all",
|
||||
collisionStrategy: "rename",
|
||||
});
|
||||
|
||||
expect(snapshot.readBinary).toHaveBeenCalled();
|
||||
expect(preview.warnings.some((w: string) => /Failed to fetch company logo/i.test(w))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ const mockAccessService = vi.hoisted(() => ({
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
importFromSource: vi.fn(),
|
||||
deleteSkill: vi.fn(),
|
||||
updateSkillAuth: vi.fn(),
|
||||
scanProjectWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
@@ -97,6 +99,15 @@ describe("company skill mutation permissions", () => {
|
||||
slug: "find-skills",
|
||||
name: "Find Skills",
|
||||
});
|
||||
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
||||
scannedProjects: 1,
|
||||
scannedWorkspaces: 2,
|
||||
discovered: [],
|
||||
imported: [],
|
||||
updated: [],
|
||||
conflicts: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
@@ -294,9 +305,120 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a PAT through skill import requests", async () => {
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({
|
||||
source: "https://github.com/vercel-labs/agent-browser",
|
||||
authToken: "ghp_private_token",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
"ghp_private_token",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a skill auth token", async () => {
|
||||
mockCompanySkillService.updateSkillAuth.mockResolvedValue({
|
||||
id: "skill-1",
|
||||
slug: "find-skills",
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.patch("/api/companies/company-1/skills/skill-1/auth")
|
||||
.send({ authToken: "ghp_private_token" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"skill-1",
|
||||
"ghp_private_token",
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "company.skill_auth_updated",
|
||||
entityType: "company_skill",
|
||||
entityId: "skill-1",
|
||||
details: { slug: "find-skills" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a skill auth token", async () => {
|
||||
mockCompanySkillService.updateSkillAuth.mockResolvedValue({
|
||||
id: "skill-1",
|
||||
slug: "find-skills",
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.patch("/api/companies/company-1/skills/skill-1/auth")
|
||||
.send({ authToken: null });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"skill-1",
|
||||
null,
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "company.skill_auth_removed",
|
||||
entityType: "company_skill",
|
||||
entityId: "skill-1",
|
||||
details: { slug: "find-skills" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows agents with canCreateAgents to scan project workspaces", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {});
|
||||
});
|
||||
|
||||
it("returns a blocking error when attempting to delete a skill still used by agents", async () => {
|
||||
const { unprocessable } = await import("../errors.js");
|
||||
mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ const mockBuildWorkspaceRealizationRequest = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateLeaseMetadata = vi.hoisted(() => vi.fn());
|
||||
const mockUpdateExecutionWorkspace = vi.hoisted(() => vi.fn());
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockEnvironmentsEnsureLocal = vi.hoisted(() => vi.fn());
|
||||
const mockEnvironmentsGetById = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/environment-execution-target.js", () => ({
|
||||
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
|
||||
@@ -26,8 +28,8 @@ vi.mock("../services/workspace-realization.js", () => ({
|
||||
|
||||
vi.mock("../services/environments.js", () => ({
|
||||
environmentService: vi.fn(() => ({
|
||||
ensureLocalEnvironment: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
ensureLocalEnvironment: mockEnvironmentsEnsureLocal,
|
||||
getById: mockEnvironmentsGetById,
|
||||
acquireLease: vi.fn(),
|
||||
releaseLease: vi.fn(),
|
||||
updateLeaseMetadata: mockUpdateLeaseMetadata,
|
||||
@@ -548,3 +550,75 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
|
||||
expect(mockResolveEnvironmentExecutionTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("environmentRunOrchestrator — acquireForRun threads agentId", () => {
|
||||
const mockDb = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// selectedEnvironmentId !== defaultEnvironmentId in our inputs so the
|
||||
// resolver goes through getById rather than ensureLocalEnvironment.
|
||||
mockEnvironmentsGetById.mockResolvedValue(makeEnvironment("sandbox"));
|
||||
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
|
||||
kind: "local",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
});
|
||||
mockAdapterExecutionTargetToRemoteSpec.mockReturnValue(null);
|
||||
});
|
||||
|
||||
function makeAcquireInput(overrides: { agentId?: string } = {}) {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
// distinct from defaultEnvironmentId so resolveEnvironment hits getById
|
||||
selectedEnvironmentId: "env-1",
|
||||
defaultEnvironmentId: "env-default",
|
||||
adapterType: "claude_local",
|
||||
issueId: null as string | null,
|
||||
heartbeatRunId: "run-1",
|
||||
agentId: overrides.agentId ?? "agent-uuid-abc",
|
||||
persistedExecutionWorkspace: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("passes agentId from acquireForRun's input through to runtime.acquireRunLease", async () => {
|
||||
const runtime = makeMockRuntime({
|
||||
acquireRunLease: vi.fn().mockResolvedValue({
|
||||
lease: makeLease(),
|
||||
leaseContext: { executionWorkspaceId: null, executionWorkspaceMode: null },
|
||||
}),
|
||||
});
|
||||
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
|
||||
|
||||
await orchestrator.acquireForRun(makeAcquireInput({ agentId: "agent-uuid-abc" }));
|
||||
|
||||
expect(runtime.acquireRunLease).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-uuid-abc",
|
||||
heartbeatRunId: "run-1",
|
||||
companyId: "company-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs the lease-acquired activity with the same agentId it threads to the runtime", async () => {
|
||||
const runtime = makeMockRuntime({
|
||||
acquireRunLease: vi.fn().mockResolvedValue({
|
||||
lease: makeLease(),
|
||||
leaseContext: { executionWorkspaceId: null, executionWorkspaceMode: null },
|
||||
}),
|
||||
});
|
||||
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
|
||||
|
||||
await orchestrator.acquireForRun(makeAcquireInput({ agentId: "agent-uuid-xyz" }));
|
||||
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
expect.objectContaining({
|
||||
action: "environment.lease_acquired",
|
||||
agentId: "agent-uuid-xyz",
|
||||
actorId: "agent-uuid-xyz",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,6 +216,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
|
||||
return {
|
||||
companyId,
|
||||
agentId,
|
||||
environment: {
|
||||
id: environmentId,
|
||||
companyId,
|
||||
@@ -1394,4 +1395,298 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
expect(sshRelease).not.toHaveBeenCalled();
|
||||
expect(acquired.lease.metadata?.driver).toBe("local");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// agentId is threaded through plugin RPC params (see protocol.ts —
|
||||
// PluginEnvironmentAcquireLeaseParams.agentId and
|
||||
// PluginEnvironmentResumeLeaseParams.agentId). Plugin-backed sandbox
|
||||
// providers can use this to scope lease state (subdirs, PVCs, etc.) per
|
||||
// agent without callbacks or DB lookups. The runtime must forward it when
|
||||
// present and omit it when null/undefined so older plugin SDKs that don't
|
||||
// declare the field aren't surprised.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("plugin-driver acquireLease: forwards agentId in the RPC payload when present", async () => {
|
||||
const pluginId = randomUUID();
|
||||
const workerManager = {
|
||||
isRunning: vi.fn(() => true),
|
||||
call: vi.fn(async (_pluginId: string, method: string) => {
|
||||
if (method === "environmentAcquireLease") {
|
||||
return { providerLeaseId: "plugin-lease-agent", metadata: { remoteCwd: "/workspace" } };
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
const { companyId, agentId, environment, runId } = await seedEnvironment({
|
||||
driver: "plugin",
|
||||
name: "Plugin agentId fwd",
|
||||
config: {
|
||||
pluginKey: "acme.environments",
|
||||
driverKey: "fake-plugin",
|
||||
driverConfig: { template: "base" },
|
||||
},
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "acme.environments",
|
||||
packageName: "@acme/paperclip-environments",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: "acme.environments",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Acme",
|
||||
description: "Test",
|
||||
author: "Acme",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
environmentDrivers: [{ driverKey: "fake-plugin", displayName: "Fake", configSchema: { type: "object" } }],
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId,
|
||||
heartbeatRunId: runId,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
|
||||
expect(workerManager.call).toHaveBeenCalledWith(
|
||||
pluginId,
|
||||
"environmentAcquireLease",
|
||||
expect.objectContaining({ agentId }),
|
||||
);
|
||||
});
|
||||
|
||||
it("plugin-driver acquireLease: omits agentId from RPC payload when null", async () => {
|
||||
const pluginId = randomUUID();
|
||||
const workerManager = {
|
||||
isRunning: vi.fn(() => true),
|
||||
call: vi.fn(async (_pluginId: string, method: string) => {
|
||||
if (method === "environmentAcquireLease") {
|
||||
return { providerLeaseId: "plugin-lease-no-agent", metadata: { remoteCwd: "/workspace" } };
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
const { companyId, environment, runId } = await seedEnvironment({
|
||||
driver: "plugin",
|
||||
name: "Plugin agentId null",
|
||||
config: {
|
||||
pluginKey: "acme.environments",
|
||||
driverKey: "fake-plugin",
|
||||
driverConfig: { template: "base" },
|
||||
},
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "acme.environments",
|
||||
packageName: "@acme/paperclip-environments",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: "acme.environments",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Acme",
|
||||
description: "Test",
|
||||
author: "Acme",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
environmentDrivers: [{ driverKey: "fake-plugin", displayName: "Fake", configSchema: { type: "object" } }],
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId: null,
|
||||
heartbeatRunId: runId,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
|
||||
const payload = (workerManager.call as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
([, method]) => method === "environmentAcquireLease",
|
||||
)?.[2] as Record<string, unknown>;
|
||||
expect(payload).toBeDefined();
|
||||
expect(payload.agentId).toBeUndefined();
|
||||
expect("agentId" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("sandbox-provider acquireLease: forwards agentId when present", async () => {
|
||||
const pluginId = randomUUID();
|
||||
const workerManager = {
|
||||
isRunning: vi.fn((id: string) => id === pluginId),
|
||||
call: vi.fn(async (_pluginId: string, method: string) => {
|
||||
if (method === "environmentAcquireLease") {
|
||||
return { providerLeaseId: "sandbox-agent-1", metadata: { reuseLease: false } };
|
||||
}
|
||||
throw new Error(`Unexpected plugin method: ${method}`);
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
const { companyId, agentId, environment, runId } = await seedEnvironment({
|
||||
driver: "sandbox",
|
||||
name: "Sandbox agentId fwd",
|
||||
config: {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:test",
|
||||
timeoutMs: 30_000,
|
||||
reuseLease: false,
|
||||
},
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "acme.sandbox",
|
||||
packageName: "@acme/paperclip-sandbox",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: "acme.sandbox",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Acme Sandbox",
|
||||
description: "Test",
|
||||
author: "Acme",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
environmentDrivers: [{
|
||||
driverKey: "fake-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake",
|
||||
configSchema: { type: "object" },
|
||||
}],
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId,
|
||||
heartbeatRunId: runId,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
|
||||
expect(workerManager.call).toHaveBeenCalledWith(
|
||||
pluginId,
|
||||
"environmentAcquireLease",
|
||||
expect.objectContaining({ agentId }),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it("sandbox-provider resumeLease: forwards agentId when present", async () => {
|
||||
const pluginId = randomUUID();
|
||||
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
||||
const workerManager = {
|
||||
isRunning: vi.fn((id: string) => id === pluginId),
|
||||
call: vi.fn(async (_pluginId: string, method: string, params: Record<string, unknown>) => {
|
||||
calls.push({ method, params });
|
||||
if (method === "environmentAcquireLease") {
|
||||
return { providerLeaseId: "sandbox-resume-1", metadata: { reuseLease: true } };
|
||||
}
|
||||
if (method === "environmentResumeLease") {
|
||||
return { providerLeaseId: "sandbox-resume-1", metadata: { reuseLease: true } };
|
||||
}
|
||||
throw new Error(`Unexpected plugin method: ${method}`);
|
||||
}),
|
||||
} as unknown as PluginWorkerManager;
|
||||
const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager });
|
||||
const { companyId, agentId, environment, runId } = await seedEnvironment({
|
||||
driver: "sandbox",
|
||||
name: "Sandbox agentId resume",
|
||||
config: {
|
||||
provider: "fake-plugin",
|
||||
image: "fake:test",
|
||||
timeoutMs: 30_000,
|
||||
reuseLease: true,
|
||||
},
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "acme.sandbox",
|
||||
packageName: "@acme/paperclip-sandbox",
|
||||
version: "1.0.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: "acme.sandbox",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Acme Sandbox",
|
||||
description: "Test",
|
||||
author: "Acme",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: { worker: "dist/worker.js" },
|
||||
environmentDrivers: [{
|
||||
driverKey: "fake-plugin",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Fake",
|
||||
configSchema: { type: "object" },
|
||||
}],
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
// First acquire seeds a reusable lease row in DB
|
||||
await runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId,
|
||||
heartbeatRunId: runId,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
|
||||
// Second acquire on the same environment + reuseLease=true exercises the
|
||||
// resume path (host's matcher finds the reusable lease, plugin's
|
||||
// resumeLease is invoked).
|
||||
const newRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: newRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
await runtimeWithPlugin.acquireRunLease({
|
||||
companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId,
|
||||
heartbeatRunId: newRunId,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
|
||||
const resumeCall = calls.find((c) => c.method === "environmentResumeLease");
|
||||
expect(resumeCall).toBeDefined();
|
||||
expect(resumeCall?.params.agentId).toBe(agentId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listServerRefs = vi.fn();
|
||||
const cloneFn = vi.fn();
|
||||
const walkFn = vi.fn();
|
||||
const readBlobFn = vi.fn();
|
||||
const resolveRefFn = vi.fn();
|
||||
const treeFn = vi.fn((args: unknown) => ({ __tree: args }));
|
||||
|
||||
vi.mock("isomorphic-git", () => ({
|
||||
default: {
|
||||
listServerRefs: (...args: unknown[]) => listServerRefs(...args),
|
||||
clone: (...args: unknown[]) => cloneFn(...args),
|
||||
walk: (...args: unknown[]) => walkFn(...args),
|
||||
readBlob: (...args: unknown[]) => readBlobFn(...args),
|
||||
resolveRef: (...args: unknown[]) => resolveRefFn(...args),
|
||||
TREE: (...args: unknown[]) => treeFn(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("isomorphic-git/http/node", () => ({
|
||||
default: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
const { parseGitSourceUrl, resolveGitRef, openRepoSnapshot, buildCloneUrl } =
|
||||
await import("../services/git-source.js");
|
||||
|
||||
beforeEach(() => {
|
||||
listServerRefs.mockReset();
|
||||
cloneFn.mockReset();
|
||||
walkFn.mockReset();
|
||||
readBlobFn.mockReset();
|
||||
resolveRefFn.mockReset();
|
||||
treeFn.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("parseGitSourceUrl", () => {
|
||||
it("parses a bare github repo URL", () => {
|
||||
expect(parseGitSourceUrl("https://github.com/anthropics/claude-code")).toMatchObject({
|
||||
cloneUrl: "https://github.com/anthropics/claude-code.git",
|
||||
hostname: "github.com",
|
||||
owner: "anthropics",
|
||||
repo: "claude-code",
|
||||
ref: null,
|
||||
basePath: "",
|
||||
filePath: null,
|
||||
explicitRef: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips trailing .git from the repo segment", () => {
|
||||
expect(parseGitSourceUrl("https://example.com/o/r.git")).toMatchObject({
|
||||
cloneUrl: "https://example.com/o/r.git",
|
||||
repo: "r",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a github tree URL with subpath", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://github.com/o/r/tree/develop/sub/dir"),
|
||||
).toMatchObject({
|
||||
ref: "develop",
|
||||
basePath: "sub/dir",
|
||||
filePath: null,
|
||||
explicitRef: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a github blob URL as a file path", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://github.com/o/r/blob/main/path/to/file.md"),
|
||||
).toMatchObject({
|
||||
ref: "main",
|
||||
basePath: "path/to",
|
||||
filePath: "path/to/file.md",
|
||||
explicitRef: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a gitea src/branch URL with subpath", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://git.example.com/o/r/src/branch/main/skills"),
|
||||
).toMatchObject({
|
||||
cloneUrl: "https://git.example.com/o/r.git",
|
||||
ref: "main",
|
||||
basePath: "skills",
|
||||
filePath: null,
|
||||
explicitRef: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a gitea src/tag URL", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.2.3"),
|
||||
).toMatchObject({
|
||||
ref: "v1.2.3",
|
||||
basePath: "",
|
||||
explicitRef: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a gitea src/commit URL with file", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://git.example.com/o/r/src/commit/abc123/dir/SKILL.md"),
|
||||
).toMatchObject({
|
||||
ref: "abc123",
|
||||
basePath: "dir",
|
||||
filePath: "dir/SKILL.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a gitlab tree URL", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://gitlab.com/group/proj/-/tree/main/sub"),
|
||||
).toMatchObject({
|
||||
cloneUrl: "https://gitlab.com/group/proj.git",
|
||||
ref: "main",
|
||||
basePath: "sub",
|
||||
explicitRef: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a gitlab blob URL", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://gitlab.com/group/proj/-/blob/main/sub/file.md"),
|
||||
).toMatchObject({
|
||||
ref: "main",
|
||||
filePath: "sub/file.md",
|
||||
basePath: "sub",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-https URLs", () => {
|
||||
expect(() => parseGitSourceUrl("http://github.com/o/r")).toThrow(/HTTPS/);
|
||||
});
|
||||
|
||||
it("rejects URLs without owner/repo", () => {
|
||||
expect(() => parseGitSourceUrl("https://github.com/o")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects malformed URLs", () => {
|
||||
expect(() => parseGitSourceUrl("not a url")).toThrow();
|
||||
});
|
||||
|
||||
it("parses a query-string URL with ?ref= and ?path=", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://github.com/o/r?ref=feature%2Fdemo&path=subdir"),
|
||||
).toMatchObject({
|
||||
cloneUrl: "https://github.com/o/r.git",
|
||||
ref: "feature/demo",
|
||||
basePath: "subdir",
|
||||
filePath: null,
|
||||
explicitRef: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a query-string URL with only ?ref=", () => {
|
||||
expect(parseGitSourceUrl("https://github.com/o/r?ref=develop")).toMatchObject({
|
||||
ref: "develop",
|
||||
basePath: "",
|
||||
explicitRef: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a query-string URL with only ?path=", () => {
|
||||
expect(parseGitSourceUrl("https://github.com/o/r?path=sub")).toMatchObject({
|
||||
ref: null,
|
||||
basePath: "sub",
|
||||
explicitRef: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("query-string parsing takes precedence over path-style segments", () => {
|
||||
expect(
|
||||
parseGitSourceUrl("https://github.com/o/r/tree/main/old?ref=newref&path=newpath"),
|
||||
).toMatchObject({
|
||||
ref: "newref",
|
||||
basePath: "newpath",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCloneUrl", () => {
|
||||
it("produces a .git suffix URL on the given host", () => {
|
||||
expect(buildCloneUrl("git.example.com", "o", "r")).toBe(
|
||||
"https://git.example.com/o/r.git",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitRef", () => {
|
||||
it("passes through a 40-hex SHA without hitting the network", async () => {
|
||||
const parsed = parseGitSourceUrl(
|
||||
"https://github.com/o/r/tree/0123456789abcdef0123456789abcdef01234567",
|
||||
);
|
||||
const result = await resolveGitRef(parsed);
|
||||
expect(result).toEqual({
|
||||
pinnedSha: "0123456789abcdef0123456789abcdef01234567",
|
||||
trackingRef: "0123456789abcdef0123456789abcdef01234567",
|
||||
});
|
||||
expect(listServerRefs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns default branch via HEAD symref when ref is absent", async () => {
|
||||
listServerRefs.mockResolvedValue([
|
||||
{ ref: "HEAD", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", target: "refs/heads/main" },
|
||||
{ ref: "refs/heads/main", oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
||||
{ ref: "refs/heads/chore", oid: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
|
||||
]);
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
const result = await resolveGitRef(parsed);
|
||||
expect(result).toEqual({
|
||||
pinnedSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
trackingRef: "main",
|
||||
});
|
||||
expect(listServerRefs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://git.example.com/o/r.git",
|
||||
symrefs: true,
|
||||
protocolVersion: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a named branch to its SHA", async () => {
|
||||
listServerRefs.mockResolvedValue([
|
||||
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
|
||||
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
|
||||
{ ref: "refs/heads/develop", oid: "2222222222222222222222222222222222222222" },
|
||||
]);
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/develop");
|
||||
const result = await resolveGitRef(parsed);
|
||||
expect(result).toEqual({
|
||||
pinnedSha: "2222222222222222222222222222222222222222",
|
||||
trackingRef: "develop",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a peeled annotated tag over the tag object", async () => {
|
||||
listServerRefs.mockResolvedValue([
|
||||
{ ref: "refs/tags/v1.0", oid: "tttttttttttttttttttttttttttttttttttttttt" },
|
||||
{ ref: "refs/tags/v1.0^{}", oid: "cccccccccccccccccccccccccccccccccccccccc" },
|
||||
]);
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v1.0");
|
||||
const result = await resolveGitRef(parsed);
|
||||
expect(result.pinnedSha).toBe("cccccccccccccccccccccccccccccccccccccccc");
|
||||
expect(result.trackingRef).toBe("v1.0");
|
||||
});
|
||||
|
||||
it("resolves a lightweight tag when no peeled entry exists", async () => {
|
||||
listServerRefs.mockResolvedValue([
|
||||
{ ref: "refs/tags/v2.0", oid: "dddddddddddddddddddddddddddddddddddddddd" },
|
||||
]);
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/tag/v2.0");
|
||||
const result = await resolveGitRef(parsed);
|
||||
expect(result.pinnedSha).toBe("dddddddddddddddddddddddddddddddddddddddd");
|
||||
});
|
||||
|
||||
it("throws when an explicit ref does not exist", async () => {
|
||||
listServerRefs.mockResolvedValue([
|
||||
{ ref: "HEAD", oid: "9999999999999999999999999999999999999999", target: "refs/heads/main" },
|
||||
{ ref: "refs/heads/main", oid: "9999999999999999999999999999999999999999" },
|
||||
]);
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r/src/branch/missing");
|
||||
await expect(resolveGitRef(parsed)).rejects.toThrow(/Ref 'missing' not found/);
|
||||
});
|
||||
|
||||
it("translates network errors into a user-facing message", async () => {
|
||||
listServerRefs.mockRejectedValue(new Error("ENOTFOUND git.invalid"));
|
||||
const parsed = parseGitSourceUrl("https://git.invalid/o/r");
|
||||
await expect(resolveGitRef(parsed)).rejects.toThrow(/could not connect/i);
|
||||
});
|
||||
|
||||
it("translates 401 errors into an auth message", async () => {
|
||||
listServerRefs.mockRejectedValue(new Error("HTTP Error: 401 Unauthorized"));
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
await expect(resolveGitRef(parsed)).rejects.toThrow(/authentication/i);
|
||||
});
|
||||
|
||||
it("translates 404 errors into a repo-not-found message", async () => {
|
||||
listServerRefs.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
await expect(resolveGitRef(parsed)).rejects.toThrow(/repository not found/i);
|
||||
});
|
||||
|
||||
it("sends an onAuth callback when a token is supplied", async () => {
|
||||
listServerRefs.mockResolvedValue([
|
||||
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
|
||||
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
|
||||
]);
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
await resolveGitRef(parsed, "tok_abc");
|
||||
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth: () => unknown };
|
||||
expect(typeof callArgs.onAuth).toBe("function");
|
||||
expect(callArgs.onAuth()).toEqual({ username: "tok_abc", password: "x-oauth-basic" });
|
||||
});
|
||||
|
||||
it("omits onAuth when no token is supplied", async () => {
|
||||
listServerRefs.mockResolvedValue([
|
||||
{ ref: "HEAD", oid: "1111111111111111111111111111111111111111", target: "refs/heads/main" },
|
||||
{ ref: "refs/heads/main", oid: "1111111111111111111111111111111111111111" },
|
||||
]);
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
await resolveGitRef(parsed);
|
||||
const callArgs = listServerRefs.mock.calls[0]![0] as { onAuth?: unknown };
|
||||
expect(callArgs.onAuth).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openRepoSnapshot", () => {
|
||||
it("clones at the tracking ref and walks the tree at the resolved SHA", async () => {
|
||||
cloneFn.mockResolvedValue(undefined);
|
||||
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
||||
walkFn.mockImplementation(async ({ map }: { map: (filepath: string, entries: Array<{ type: () => Promise<string> }>) => Promise<void> }) => {
|
||||
await map(".", [{ type: () => Promise.resolve("tree") }]);
|
||||
await map("README.md", [{ type: () => Promise.resolve("blob") }]);
|
||||
await map("skills/x/SKILL.md", [{ type: () => Promise.resolve("blob") }]);
|
||||
await map("skills/x", [{ type: () => Promise.resolve("tree") }]);
|
||||
});
|
||||
readBlobFn.mockResolvedValue({ blob: new TextEncoder().encode("hello") });
|
||||
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff", "tok");
|
||||
|
||||
expect(cloneFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://git.example.com/o/r.git",
|
||||
ref: "main",
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
noCheckout: true,
|
||||
}),
|
||||
);
|
||||
expect(snap.sha).toBe("ffffffffffffffffffffffffffffffffffffffff");
|
||||
|
||||
const files = await snap.listFiles();
|
||||
expect(files).toEqual(["README.md", "skills/x/SKILL.md"]);
|
||||
|
||||
const content = await snap.readFile("README.md");
|
||||
expect(content).toBe("hello");
|
||||
expect(readBlobFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
oid: "ffffffffffffffffffffffffffffffffffffffff",
|
||||
filepath: "README.md",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the expected SHA as ref when no tracking ref is known", async () => {
|
||||
cloneFn.mockResolvedValue(undefined);
|
||||
resolveRefFn.mockResolvedValue("abc1234567890abc1234567890abc1234567890a");
|
||||
walkFn.mockImplementation(async () => {});
|
||||
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
await openRepoSnapshot(parsed, null, "abc1234567890abc1234567890abc1234567890a");
|
||||
|
||||
expect(cloneFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ref: "abc1234567890abc1234567890abc1234567890a" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a 404 from clone as repository-not-found", async () => {
|
||||
cloneFn.mockRejectedValue(new Error("HTTP Error: 404 Not Found"));
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
await expect(
|
||||
openRepoSnapshot(parsed, "main", "1111111111111111111111111111111111111111"),
|
||||
).rejects.toThrow(/repository not found/i);
|
||||
});
|
||||
|
||||
it("readBinary returns the raw blob bytes", async () => {
|
||||
cloneFn.mockResolvedValue(undefined);
|
||||
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
||||
walkFn.mockImplementation(async () => {});
|
||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
readBlobFn.mockResolvedValue({ blob: bytes });
|
||||
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
|
||||
const result = await snap.readBinary("logo.png");
|
||||
expect(result).toBe(bytes);
|
||||
});
|
||||
|
||||
it("readFileOptional returns null on NotFoundError", async () => {
|
||||
cloneFn.mockResolvedValue(undefined);
|
||||
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
||||
walkFn.mockImplementation(async () => {});
|
||||
const err = Object.assign(new Error("missing"), { code: "NotFoundError" });
|
||||
readBlobFn.mockRejectedValue(err);
|
||||
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
|
||||
const result = await snap.readFileOptional("missing.md");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("readFileOptional rethrows non-NotFound errors", async () => {
|
||||
cloneFn.mockResolvedValue(undefined);
|
||||
resolveRefFn.mockResolvedValue("ffffffffffffffffffffffffffffffffffffffff");
|
||||
walkFn.mockImplementation(async () => {});
|
||||
readBlobFn.mockRejectedValue(new Error("disk explosion"));
|
||||
|
||||
const parsed = parseGitSourceUrl("https://git.example.com/o/r");
|
||||
const snap = await openRepoSnapshot(parsed, "main", "ffffffffffffffffffffffffffffffffffffffff");
|
||||
await expect(snap.readFileOptional("any.md")).rejects.toThrow(/disk explosion/);
|
||||
});
|
||||
});
|
||||
@@ -209,6 +209,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
issueId: null,
|
||||
config: { template: "base" },
|
||||
runId: run!.id,
|
||||
agentId,
|
||||
workspaceMode: "shared_workspace",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
@@ -426,6 +427,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
issueId,
|
||||
config: { template: "new" },
|
||||
runId: run!.id,
|
||||
agentId,
|
||||
workspaceMode: "shared_workspace",
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
@@ -320,6 +320,7 @@ export function agentRoutes(
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId: null,
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
companySkillCreateSchema,
|
||||
companySkillFileUpdateSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillUpdateAuthSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||
@@ -32,12 +33,6 @@ export function companySkillRoutes(db: Db) {
|
||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null {
|
||||
if (skill.sourceType === "skills_sh") {
|
||||
return skill.key;
|
||||
@@ -45,10 +40,6 @@ export function companySkillRoutes(db: Db) {
|
||||
if (skill.sourceType !== "github") {
|
||||
return null;
|
||||
}
|
||||
const hostname = asString(skill.metadata?.hostname);
|
||||
if (hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
return skill.key;
|
||||
}
|
||||
|
||||
@@ -194,7 +185,8 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const source = String(req.body.source ?? "");
|
||||
const result = await svc.importFromSource(companyId, source);
|
||||
const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
|
||||
const result = await svc.importFromSource(companyId, source, authToken || undefined);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -318,5 +310,38 @@ export function companySkillRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/skills/:skillId/auth",
|
||||
validate(companySkillUpdateAuthSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const authToken = req.body.authToken as string | null;
|
||||
const result = await svc.updateSkillAuth(companyId, skillId, authToken);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
|
||||
entityType: "company_skill",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
slug: result.slug,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ import type {
|
||||
CompanyPortabilityIssueManifestEntry,
|
||||
CompanyPortabilitySidebarOrder,
|
||||
CompanyPortabilitySkillManifestEntry,
|
||||
CompanyPortabilitySecretEntry,
|
||||
CompanySkill,
|
||||
AgentEnvConfig,
|
||||
RoutineVariable,
|
||||
SecretProvider,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
@@ -54,8 +56,8 @@ import {
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { findServerAdapter } from "../adapters/index.js";
|
||||
import { forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||
import { forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { openRepoSnapshot, parseGitSourceUrl, resolveGitRef, type RepoSnapshot } from "./git-source.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { agentService } from "./agents.js";
|
||||
@@ -403,7 +405,7 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function extractPortableScopedEnvInputs(
|
||||
async function extractPortableScopedEnvInputs(
|
||||
scope: {
|
||||
label: string;
|
||||
warningPrefix: string;
|
||||
@@ -412,7 +414,11 @@ function extractPortableScopedEnvInputs(
|
||||
},
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
|
||||
secretEntries: CompanyPortabilitySecretEntry[],
|
||||
includeSecrets: boolean,
|
||||
companyId: string,
|
||||
): Promise<CompanyPortabilityEnvInput[]> {
|
||||
if (!isPlainRecord(envValue)) return [];
|
||||
const env = envValue as Record<string, unknown>;
|
||||
const inputs: CompanyPortabilityEnvInput[] = [];
|
||||
@@ -424,6 +430,7 @@ function extractPortableScopedEnvInputs(
|
||||
}
|
||||
|
||||
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
||||
const secret = await secrets.getById(String(binding.secretId));
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Provide ${key} for ${scope.label}`,
|
||||
@@ -433,7 +440,33 @@ function extractPortableScopedEnvInputs(
|
||||
requirement: "optional",
|
||||
defaultValue: "",
|
||||
portability: "portable",
|
||||
secretName: secret?.name ?? null,
|
||||
secretProvider: secret?.provider ?? null,
|
||||
});
|
||||
if (includeSecrets && secret && binding.secretId) {
|
||||
const alreadyExported = secretEntries.some((e) => e.name === secret.name);
|
||||
if (!alreadyExported) {
|
||||
try {
|
||||
const resolvedValue = await secrets.resolveSecretValue(companyId, String(binding.secretId), "latest");
|
||||
secretEntries.push({
|
||||
name: secret.name,
|
||||
provider: secret.provider as SecretProvider,
|
||||
description: secret.description,
|
||||
latestVersion: secret.latestVersion,
|
||||
currentValue: resolvedValue,
|
||||
});
|
||||
} catch {
|
||||
secretEntries.push({
|
||||
name: secret.name,
|
||||
provider: secret.provider as SecretProvider,
|
||||
description: secret.description,
|
||||
latestVersion: secret.latestVersion,
|
||||
currentValue: `<decryption-key-missing:${secret.name}>`,
|
||||
});
|
||||
warnings.push(`Secret "${secret.name}" could not be decrypted during export. Placeholder written.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -443,9 +476,6 @@ function extractPortableScopedEnvInputs(
|
||||
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
||||
? "system_dependent"
|
||||
: "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on ${scope.label}`,
|
||||
@@ -461,9 +491,6 @@ function extractPortableScopedEnvInputs(
|
||||
|
||||
if (typeof binding === "string") {
|
||||
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
||||
if (portability === "system_dependent") {
|
||||
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
||||
}
|
||||
inputs.push({
|
||||
key,
|
||||
description: `Optional default for ${key} on ${scope.label}`,
|
||||
@@ -571,11 +598,14 @@ type AgentLike = {
|
||||
};
|
||||
|
||||
type EnvInputRecord = {
|
||||
type?: "secret_ref" | "plain";
|
||||
kind: "secret" | "plain";
|
||||
requirement: "required" | "optional";
|
||||
default?: string | null;
|
||||
description?: string | null;
|
||||
portability?: "portable" | "system_dependent";
|
||||
secretName?: string | null;
|
||||
secretProvider?: string | null;
|
||||
};
|
||||
|
||||
const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
||||
@@ -1717,11 +1747,15 @@ function isAbsoluteCommand(value: string) {
|
||||
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function extractPortableEnvInputs(
|
||||
async function extractPortableEnvInputs(
|
||||
agentSlug: string,
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
|
||||
secretEntries: CompanyPortabilitySecretEntry[],
|
||||
includeSecrets: boolean,
|
||||
companyId: string,
|
||||
): Promise<CompanyPortabilityEnvInput[]> {
|
||||
return extractPortableScopedEnvInputs(
|
||||
{
|
||||
label: `agent ${agentSlug}`,
|
||||
@@ -1731,14 +1765,22 @@ function extractPortableEnvInputs(
|
||||
},
|
||||
envValue,
|
||||
warnings,
|
||||
secrets,
|
||||
secretEntries,
|
||||
includeSecrets,
|
||||
companyId,
|
||||
);
|
||||
}
|
||||
|
||||
function extractPortableProjectEnvInputs(
|
||||
async function extractPortableProjectEnvInputs(
|
||||
projectSlug: string,
|
||||
envValue: unknown,
|
||||
warnings: string[],
|
||||
): CompanyPortabilityEnvInput[] {
|
||||
secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise<string> },
|
||||
secretEntries: CompanyPortabilitySecretEntry[],
|
||||
includeSecrets: boolean,
|
||||
companyId: string,
|
||||
): Promise<CompanyPortabilityEnvInput[]> {
|
||||
return extractPortableScopedEnvInputs(
|
||||
{
|
||||
label: `project ${projectSlug}`,
|
||||
@@ -1748,6 +1790,10 @@ function extractPortableProjectEnvInputs(
|
||||
},
|
||||
envValue,
|
||||
warnings,
|
||||
secrets,
|
||||
secretEntries,
|
||||
includeSecrets,
|
||||
companyId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2293,42 +2339,6 @@ function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchText(url: string) {
|
||||
const response = await ghFetch(url);
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function fetchOptionalText(url: string) {
|
||||
const response = await ghFetch(url);
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function fetchBinary(url: string) {
|
||||
const response = await ghFetch(url);
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await ghFetch(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
|
||||
const seen = new Set<string>();
|
||||
@@ -2352,6 +2362,13 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
|
||||
if (input.defaultValue !== null) entry.default = input.defaultValue;
|
||||
if (input.description) entry.description = input.description;
|
||||
if (input.portability === "system_dependent") entry.portability = "system_dependent";
|
||||
if (input.secretName) {
|
||||
entry.secretName = input.secretName;
|
||||
entry.type = "secret_ref";
|
||||
} else {
|
||||
entry.type = "plain";
|
||||
}
|
||||
if (input.secretProvider) entry.secretProvider = input.secretProvider;
|
||||
env[input.key] = entry;
|
||||
}
|
||||
return env;
|
||||
@@ -2396,6 +2413,9 @@ function readAgentEnvInputs(
|
||||
requirement: record.requirement === "required" ? "required" : "optional",
|
||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
||||
secretName: record.secretName ?? null,
|
||||
secretProvider: record.secretProvider ?? null,
|
||||
type: record.type,
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -2420,6 +2440,9 @@ function readProjectEnvInputs(
|
||||
requirement: record.requirement === "required" ? "required" : "optional",
|
||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
||||
secretName: record.secretName ?? null,
|
||||
secretProvider: record.secretProvider ?? null,
|
||||
type: record.type,
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -2466,6 +2489,7 @@ function buildManifestFromPackageFiles(
|
||||
const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {};
|
||||
const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {};
|
||||
const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {};
|
||||
const paperclipSecrets = Array.isArray(paperclipExtension.secrets) ? paperclipExtension.secrets : [];
|
||||
const companyName =
|
||||
asString(companyFrontmatter.name)
|
||||
?? opts?.sourceLabel?.companyName
|
||||
@@ -2549,6 +2573,7 @@ function buildManifestFromPackageFiles(
|
||||
projects: [],
|
||||
issues: [],
|
||||
envInputs: [],
|
||||
secrets: paperclipSecrets.length > 0 ? paperclipSecrets : undefined,
|
||||
};
|
||||
|
||||
const warnings: string[] = [];
|
||||
@@ -2803,52 +2828,37 @@ function normalizeGitHubSourcePath(value: string | null | undefined) {
|
||||
|
||||
export function parseGitHubSourceUrl(rawUrl: string) {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol !== "https:") {
|
||||
throw unprocessable("GitHub source URL must use HTTPS");
|
||||
}
|
||||
const hostname = url.hostname;
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
throw unprocessable("Invalid GitHub URL");
|
||||
}
|
||||
const owner = parts[0]!;
|
||||
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||
const queryRef = url.searchParams.get("ref")?.trim();
|
||||
const queryPath = normalizeGitHubSourcePath(url.searchParams.get("path"));
|
||||
// Handle the portability-specific companyPath query param before delegating,
|
||||
// since git-source has no notion of it.
|
||||
const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath"));
|
||||
if (queryRef || queryPath || queryCompanyPath) {
|
||||
const companyPath = queryCompanyPath || [queryPath, "COMPANY.md"].filter(Boolean).join("/") || "COMPANY.md";
|
||||
let basePath = queryPath;
|
||||
if (!basePath && companyPath !== "COMPANY.md") {
|
||||
basePath = path.posix.dirname(companyPath);
|
||||
if (basePath === ".") basePath = "";
|
||||
|
||||
const parsed = parseGitSourceUrl(rawUrl);
|
||||
|
||||
let companyPath: string;
|
||||
let basePath = parsed.basePath;
|
||||
if (queryCompanyPath) {
|
||||
companyPath = queryCompanyPath;
|
||||
if (!basePath) {
|
||||
const derived = path.posix.dirname(companyPath);
|
||||
basePath = derived === "." ? "" : derived;
|
||||
}
|
||||
return {
|
||||
hostname,
|
||||
owner,
|
||||
repo,
|
||||
ref: queryRef || "main",
|
||||
basePath,
|
||||
companyPath,
|
||||
};
|
||||
} else if (parsed.filePath) {
|
||||
// blob-style URL pointed directly at a file
|
||||
companyPath = parsed.filePath;
|
||||
} else if (basePath) {
|
||||
companyPath = `${basePath}/COMPANY.md`;
|
||||
} else {
|
||||
companyPath = "COMPANY.md";
|
||||
}
|
||||
let ref = "main";
|
||||
let basePath = "";
|
||||
let companyPath = "COMPANY.md";
|
||||
if (parts[2] === "tree") {
|
||||
ref = parts[3] ?? "main";
|
||||
basePath = parts.slice(4).join("/");
|
||||
} else if (parts[2] === "blob") {
|
||||
ref = parts[3] ?? "main";
|
||||
const blobPath = parts.slice(4).join("/");
|
||||
if (!blobPath) {
|
||||
throw unprocessable("Invalid GitHub blob URL");
|
||||
}
|
||||
companyPath = blobPath;
|
||||
basePath = path.posix.dirname(blobPath);
|
||||
if (basePath === ".") basePath = "";
|
||||
}
|
||||
return { hostname, owner, repo, ref, basePath, companyPath };
|
||||
|
||||
return {
|
||||
hostname: parsed.hostname,
|
||||
owner: parsed.owner,
|
||||
repo: parsed.repo,
|
||||
ref: parsed.ref ?? "main",
|
||||
basePath,
|
||||
companyPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2952,30 +2962,38 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseGitHubSourceUrl(source.url);
|
||||
let ref = parsed.ref;
|
||||
const sourceUrl = source.url;
|
||||
const parsed = parseGitHubSourceUrl(sourceUrl);
|
||||
const warnings: string[] = [];
|
||||
const companyRelativePath = parsed.companyPath === "COMPANY.md"
|
||||
? [parsed.basePath, "COMPANY.md"].filter(Boolean).join("/")
|
||||
: parsed.companyPath;
|
||||
|
||||
async function openSnapshot(refName: string): Promise<RepoSnapshot> {
|
||||
const ps = parseGitSourceUrl(sourceUrl);
|
||||
const wanted = { ...ps, ref: refName, explicitRef: true };
|
||||
const resolved = await resolveGitRef(wanted);
|
||||
return openRepoSnapshot(wanted, resolved.trackingRef, resolved.pinnedSha);
|
||||
}
|
||||
|
||||
let ref = parsed.ref;
|
||||
let snapshot: RepoSnapshot;
|
||||
let companyMarkdown: string | null = null;
|
||||
try {
|
||||
companyMarkdown = await fetchOptionalText(
|
||||
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
|
||||
);
|
||||
snapshot = await openSnapshot(ref);
|
||||
companyMarkdown = await snapshot.readFileOptional(companyRelativePath);
|
||||
} catch (err) {
|
||||
if (ref === "main") {
|
||||
ref = "master";
|
||||
warnings.push("GitHub ref main not found; falling back to master.");
|
||||
companyMarkdown = await fetchOptionalText(
|
||||
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, companyRelativePath),
|
||||
);
|
||||
warnings.push("Git ref main not found; falling back to master.");
|
||||
snapshot = await openSnapshot(ref);
|
||||
companyMarkdown = await snapshot.readFileOptional(companyRelativePath);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!companyMarkdown) {
|
||||
throw unprocessable("GitHub company package is missing COMPANY.md");
|
||||
throw unprocessable("Git company package is missing COMPANY.md");
|
||||
}
|
||||
|
||||
const companyPath = parsed.companyPath === "COMPANY.md"
|
||||
@@ -2984,31 +3002,22 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const files: Record<string, CompanyPortabilityFileEntry> = {
|
||||
[companyPath]: companyMarkdown,
|
||||
};
|
||||
const apiBase = gitHubApiBase(parsed.hostname);
|
||||
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||
).catch(() => ({ tree: [] }));
|
||||
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
||||
const candidatePaths = (tree.tree ?? [])
|
||||
.filter((entry) => entry.type === "blob")
|
||||
.map((entry) => entry.path)
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.filter((entry) => {
|
||||
if (basePrefix && !entry.startsWith(basePrefix)) return false;
|
||||
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
|
||||
return (
|
||||
relative.endsWith(".md") ||
|
||||
relative.startsWith("skills/") ||
|
||||
relative === ".paperclip.yaml" ||
|
||||
relative === ".paperclip.yml"
|
||||
);
|
||||
});
|
||||
const allPaths = await snapshot.listFiles();
|
||||
const candidatePaths = allPaths.filter((entry) => {
|
||||
if (basePrefix && !entry.startsWith(basePrefix)) return false;
|
||||
const relative = basePrefix ? entry.slice(basePrefix.length) : entry;
|
||||
return (
|
||||
relative.endsWith(".md") ||
|
||||
relative.startsWith("skills/") ||
|
||||
relative === ".paperclip.yaml" ||
|
||||
relative === ".paperclip.yml"
|
||||
);
|
||||
});
|
||||
for (const repoPath of candidatePaths) {
|
||||
const relativePath = basePrefix ? repoPath.slice(basePrefix.length) : repoPath;
|
||||
if (files[relativePath] !== undefined) continue;
|
||||
files[normalizePortablePath(relativePath)] = await fetchText(
|
||||
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
|
||||
);
|
||||
files[normalizePortablePath(relativePath)] = await snapshot.readFile(repoPath);
|
||||
}
|
||||
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
||||
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
|
||||
@@ -3017,9 +3026,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const relativePath = normalizePortablePath(includeEntry.path);
|
||||
if (files[relativePath] !== undefined) continue;
|
||||
if (!(repoPath.endsWith(".md") || repoPath.endsWith(".yaml") || repoPath.endsWith(".yml"))) continue;
|
||||
files[relativePath] = await fetchText(
|
||||
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
|
||||
);
|
||||
files[relativePath] = await snapshot.readFile(repoPath);
|
||||
}
|
||||
|
||||
const resolved = buildManifestFromPackageFiles(files);
|
||||
@@ -3027,12 +3034,13 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
if (companyLogoPath && !resolved.files[companyLogoPath]) {
|
||||
const repoPath = [parsed.basePath, companyLogoPath].filter(Boolean).join("/");
|
||||
try {
|
||||
const binary = await fetchBinary(
|
||||
resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoPath),
|
||||
const binary = await snapshot.readBinary(repoPath);
|
||||
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(
|
||||
Buffer.from(binary),
|
||||
inferContentTypeFromPath(companyLogoPath),
|
||||
);
|
||||
resolved.files[companyLogoPath] = bufferToPortableBinaryFile(binary, inferContentTypeFromPath(companyLogoPath));
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to fetch company logo ${companyLogoPath} from GitHub: ${err instanceof Error ? err.message : String(err)}`);
|
||||
warnings.push(`Failed to fetch company logo ${companyLogoPath} from git: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
resolved.warnings.unshift(...warnings);
|
||||
@@ -3059,7 +3067,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||
const warnings: string[] = [];
|
||||
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
|
||||
const secretEntries: CompanyPortabilitySecretEntry[] = [];
|
||||
const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder);
|
||||
const includeSecrets = input.includeSecrets === true;
|
||||
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
||||
let companyLogoPath: string | null = null;
|
||||
|
||||
@@ -3339,10 +3349,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
warnings.push(...exportedInstructions.warnings);
|
||||
|
||||
const envInputsStart = envInputs.length;
|
||||
const exportedEnvInputs = extractPortableEnvInputs(
|
||||
const exportedEnvInputs = await extractPortableEnvInputs(
|
||||
slug,
|
||||
(agent.adapterConfig as Record<string, unknown>).env,
|
||||
warnings,
|
||||
secrets,
|
||||
secretEntries,
|
||||
includeSecrets,
|
||||
companyId,
|
||||
);
|
||||
envInputs.push(...exportedEnvInputs);
|
||||
const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? [];
|
||||
@@ -3419,7 +3433,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const slug = projectSlugById.get(project.id)!;
|
||||
const projectPath = `projects/${slug}/PROJECT.md`;
|
||||
const envInputsStart = envInputs.length;
|
||||
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
|
||||
const exportedEnvInputs = await extractPortableProjectEnvInputs(slug, project.env, warnings, secrets, secretEntries, includeSecrets, companyId);
|
||||
envInputs.push(...exportedEnvInputs);
|
||||
const projectEnvInputs = dedupeEnvInputs(
|
||||
envInputs
|
||||
@@ -3639,8 +3653,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
skills: resolved.manifest.skills.length > 0,
|
||||
};
|
||||
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
||||
if (includeSecrets) {
|
||||
resolved.manifest.secrets = secretEntries.length > 0 ? secretEntries : undefined;
|
||||
}
|
||||
resolved.warnings.unshift(...warnings);
|
||||
|
||||
// Rebuild the YAML file to include secrets so files stay in sync with manifest
|
||||
// Only include secrets - other fields should come from the original YAML structure
|
||||
if (includeSecrets && resolved.manifest.secrets) {
|
||||
// Parse existing YAML and add secrets to it
|
||||
const existingYaml = parseYamlFile(readPortableTextFile(finalFiles, paperclipExtensionPath) ?? "") ?? {};
|
||||
existingYaml.secrets = resolved.manifest.secrets;
|
||||
finalFiles[paperclipExtensionPath] = buildYamlFile(existingYaml, { preserveEmptyStrings: true });
|
||||
}
|
||||
|
||||
return {
|
||||
rootPath,
|
||||
manifest: resolved.manifest,
|
||||
@@ -4198,6 +4224,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const resultAgents: CompanyPortabilityImportResult["agents"] = [];
|
||||
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
|
||||
const importedSlugToAgentId = new Map<string, string>();
|
||||
const secretNameToId = new Map<string, string>();
|
||||
const existingSlugToAgentId = new Map<string, string>();
|
||||
const agentStatusById = new Map<string, string | null | undefined>();
|
||||
const existingAgents = await agents.list(targetCompany.id);
|
||||
@@ -4229,6 +4256,35 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create secrets in target company and build name->id map
|
||||
for (const secretEntry of sourceManifest.secrets ?? []) {
|
||||
if (secretEntry.currentValue.startsWith("<decryption-key-missing:")) {
|
||||
warnings.push(`Secret "${secretEntry.name}" could not be decrypted in source instance. ` +
|
||||
`Placeholder written for key. Create a secret with this name and update manually.`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const created = await secrets.create(targetCompany.id, {
|
||||
name: secretEntry.name,
|
||||
provider: secretEntry.provider,
|
||||
value: secretEntry.currentValue,
|
||||
description: secretEntry.description,
|
||||
});
|
||||
secretNameToId.set(secretEntry.name, created.id);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 409) {
|
||||
const existing = await secrets.getByName(targetCompany.id, secretEntry.name);
|
||||
if (existing) {
|
||||
secretNameToId.set(secretEntry.name, existing.id);
|
||||
} else {
|
||||
warnings.push(`Secret "${secretEntry.name}" already exists but could not be resolved by name. Re-add env bindings for this secret manually.`);
|
||||
}
|
||||
} else {
|
||||
warnings.push(`Failed to create secret "${secretEntry.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (include.agents) {
|
||||
for (const planAgent of plan.preview.plan.agentPlans) {
|
||||
const manifestAgent = plan.selectedAgents.find((agent) => agent.slug === planAgent.slug);
|
||||
@@ -4285,6 +4341,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
desiredSkills,
|
||||
mode,
|
||||
);
|
||||
|
||||
// Reconstruct adapterConfig.env from manifest.envInputs for this agent
|
||||
const agentEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.agentSlug === manifestAgent.slug);
|
||||
if (agentEnvInputs.length > 0) {
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const ei of agentEnvInputs) {
|
||||
if (ei.kind === "secret" && ei.secretName) {
|
||||
const newSecretId = secretNameToId.get(ei.secretName);
|
||||
if (newSecretId) {
|
||||
env[ei.key] = { type: "secret_ref", secretId: newSecretId };
|
||||
} else {
|
||||
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
|
||||
}
|
||||
} else if (ei.kind === "secret" && !ei.secretName) {
|
||||
warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
|
||||
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
|
||||
env[ei.key] = { type: "plain", value: ei.defaultValue };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) {
|
||||
normalizedAdapter.adapterConfig.env = await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, env as any, { strictMode: strictSecretsMode });
|
||||
}
|
||||
}
|
||||
|
||||
const patch = {
|
||||
name: planAgent.plannedName,
|
||||
role: manifestAgent.role,
|
||||
@@ -4335,10 +4415,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdStatus = "idle";
|
||||
let created = await agents.create(targetCompany.id, {
|
||||
...patch,
|
||||
status: createdStatus,
|
||||
status: "idle",
|
||||
});
|
||||
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
|
||||
await access.setPrincipalPermission(
|
||||
@@ -4358,7 +4437,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
agentStatusById.set(created.id, created.status ?? createdStatus);
|
||||
agentStatusById.set(created.id, created.status ?? "idle");
|
||||
importedSlugToAgentId.set(planAgent.slug, created.id);
|
||||
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
|
||||
resultAgents.push({
|
||||
@@ -4407,6 +4486,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
?? null
|
||||
: null;
|
||||
const projectWorkspaceIdByKey = new Map<string, string>();
|
||||
// Build project env from manifest.envInputs filtered by this project
|
||||
const projectEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.projectSlug === planProject.slug);
|
||||
const reconstructedProjectEnv: Record<string, unknown> = {};
|
||||
for (const ei of projectEnvInputs) {
|
||||
if (ei.kind === "secret" && ei.secretName) {
|
||||
const newSecretId = secretNameToId.get(ei.secretName);
|
||||
if (newSecretId) {
|
||||
reconstructedProjectEnv[ei.key] = { type: "secret_ref", secretId: newSecretId };
|
||||
} else {
|
||||
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`);
|
||||
}
|
||||
} else if (ei.kind === "secret" && !ei.secretName) {
|
||||
warnings.push(`Env key "${ei.key}" for project ${planProject.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`);
|
||||
} else if (ei.kind === "plain" && ei.defaultValue !== null) {
|
||||
reconstructedProjectEnv[ei.key] = { type: "plain", value: ei.defaultValue };
|
||||
}
|
||||
}
|
||||
const projectEnvConfig = Object.keys(reconstructedProjectEnv).length > 0
|
||||
? await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, reconstructedProjectEnv as any, { strictMode: strictSecretsMode })
|
||||
: null;
|
||||
const projectPatch = {
|
||||
name: planProject.plannedName,
|
||||
description: manifestProject.description,
|
||||
@@ -4416,7 +4515,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
||||
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
||||
: "backlog",
|
||||
env: manifestProject.env,
|
||||
env: projectEnvConfig ?? undefined,
|
||||
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
|
||||
};
|
||||
|
||||
@@ -4495,6 +4594,91 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remap secret_ref bindings in imported agent/project records to target company secret IDs
|
||||
for (const envInput of sourceManifest.envInputs ?? []) {
|
||||
if (envInput.kind !== "secret" || !envInput.secretName) continue;
|
||||
const newSecretId = secretNameToId.get(envInput.secretName);
|
||||
if (!newSecretId) {
|
||||
// secret wasn't created (decryption failure or error) — it's already a placeholder in the env
|
||||
continue;
|
||||
}
|
||||
if (envInput.agentSlug) {
|
||||
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
|
||||
if (agentId) {
|
||||
const agent = await agents.getById(agentId);
|
||||
if (agent) {
|
||||
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
|
||||
const env = adapterConfig.env as Record<string, unknown> | undefined;
|
||||
let mutated = false;
|
||||
if (env && typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
|
||||
const binding = env[envInput.key] as Record<string, unknown>;
|
||||
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
|
||||
binding.secretId = newSecretId;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (mutated) await agents.update(agentId, { adapterConfig });
|
||||
}
|
||||
}
|
||||
} else if (envInput.projectSlug) {
|
||||
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
|
||||
if (projectId) {
|
||||
const project = await projects.getById(projectId);
|
||||
if (project && project.env && typeof project.env === "object") {
|
||||
const env = project.env as Record<string, unknown>;
|
||||
let mutated = false;
|
||||
if (typeof env[envInput.key] === "object" && env[envInput.key] !== null) {
|
||||
const binding = env[envInput.key] as Record<string, unknown>;
|
||||
if (binding.type === "secret_ref" && binding.secretId !== newSecretId) {
|
||||
binding.secretId = newSecretId;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the legacy secret remapping below is kept as a safety net for
|
||||
// agents/projects that were created/updated before this code existed.
|
||||
// It can be removed once the inline reconstruction above is stable.
|
||||
// Reconstruct plain env bindings and fill in missing env keys on imported agents/projects
|
||||
for (const envInput of sourceManifest.envInputs ?? []) {
|
||||
if (envInput.kind !== "plain" && !(envInput.kind === "secret" && !envInput.secretName)) continue;
|
||||
if (!envInput.defaultValue && envInput.kind === "plain") continue;
|
||||
|
||||
if (envInput.agentSlug) {
|
||||
const agentId = importedSlugToAgentId.get(envInput.agentSlug);
|
||||
if (!agentId) continue;
|
||||
const agent = await agents.getById(agentId);
|
||||
if (!agent) continue;
|
||||
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
|
||||
const env = (adapterConfig.env as Record<string, unknown>) ?? {};
|
||||
let mutated = false;
|
||||
if (!env[envInput.key] && envInput.kind === "plain") {
|
||||
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
|
||||
mutated = true;
|
||||
}
|
||||
if (mutated) {
|
||||
adapterConfig.env = env;
|
||||
await agents.update(agentId, { adapterConfig });
|
||||
}
|
||||
} else if (envInput.projectSlug) {
|
||||
const projectId = importedSlugToProjectId.get(envInput.projectSlug);
|
||||
if (!projectId) continue;
|
||||
const project = await projects.getById(projectId);
|
||||
if (!project) continue;
|
||||
const env = (project.env as Record<string, unknown>) ?? {};
|
||||
let mutated = false;
|
||||
if (!env[envInput.key] && envInput.kind === "plain") {
|
||||
env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" };
|
||||
mutated = true;
|
||||
}
|
||||
if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig });
|
||||
}
|
||||
}
|
||||
|
||||
if (include.issues) {
|
||||
const routines = routineService(db);
|
||||
for (const manifestIssue of sourceManifest.issues) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, companySkills } from "@paperclipai/db";
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -29,9 +29,10 @@ import type {
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||
import { openRepoSnapshot, parseGitSourceUrl, resolveGitRef, type ParsedGitSource, type RepoSnapshot } from "./git-source.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
|
||||
type CompanySkillRow = typeof companySkills.$inferSelect;
|
||||
type CompanySkillListDbRow = Pick<
|
||||
@@ -540,89 +541,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchText(url: string) {
|
||||
const response = await ghFetch(url);
|
||||
async function fetchPlainText(url: string) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
const hostname = (() => { try { return new URL(url).hostname; } catch { return url; } })();
|
||||
throw unprocessable(`Could not connect to ${hostname}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await ghFetch(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
|
||||
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
|
||||
const response = await fetchJson<{ default_branch?: string }>(
|
||||
`${apiBase}/repos/${owner}/${repo}`,
|
||||
);
|
||||
return asString(response.default_branch) ?? "main";
|
||||
}
|
||||
|
||||
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
|
||||
const response = await fetchJson<{ sha?: string }>(
|
||||
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||
);
|
||||
const sha = asString(response.sha);
|
||||
if (!sha) {
|
||||
throw unprocessable(`Failed to resolve GitHub ref ${ref}`);
|
||||
}
|
||||
return sha;
|
||||
}
|
||||
|
||||
function parseGitHubSourceUrl(rawUrl: string) {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol !== "https:") {
|
||||
throw unprocessable("GitHub source URL must use HTTPS");
|
||||
}
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
throw unprocessable("Invalid GitHub URL");
|
||||
}
|
||||
const owner = parts[0]!;
|
||||
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||
let ref = "main";
|
||||
let basePath = "";
|
||||
let filePath: string | null = null;
|
||||
let explicitRef = false;
|
||||
if (parts[2] === "tree") {
|
||||
ref = parts[3] ?? "main";
|
||||
basePath = parts.slice(4).join("/");
|
||||
explicitRef = true;
|
||||
} else if (parts[2] === "blob") {
|
||||
ref = parts[3] ?? "main";
|
||||
filePath = parts.slice(4).join("/");
|
||||
basePath = filePath ? path.posix.dirname(filePath) : "";
|
||||
explicitRef = true;
|
||||
}
|
||||
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
||||
}
|
||||
|
||||
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
|
||||
const apiBase = gitHubApiBase(parsed.hostname);
|
||||
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
||||
return {
|
||||
pinnedRef: parsed.ref,
|
||||
trackingRef: parsed.explicitRef ? parsed.ref : null,
|
||||
};
|
||||
}
|
||||
|
||||
const trackingRef = parsed.explicitRef
|
||||
? parsed.ref
|
||||
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
|
||||
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
|
||||
return { pinnedRef, trackingRef };
|
||||
}
|
||||
|
||||
|
||||
function extractCommandTokens(raw: string) {
|
||||
const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
||||
@@ -1050,6 +982,7 @@ async function readUrlSkillImports(
|
||||
companyId: string,
|
||||
sourceUrl: string,
|
||||
requestedSkillSlug: string | null = null,
|
||||
authToken?: string,
|
||||
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||
const url = sourceUrl.trim();
|
||||
const warnings: string[] = [];
|
||||
@@ -1062,19 +995,12 @@ async function readUrlSkillImports(
|
||||
return segments.length >= 2 && !parsed.pathname.endsWith(".md");
|
||||
} catch { return false; } })();
|
||||
if (looksLikeRepoUrl) {
|
||||
const parsed = parseGitHubSourceUrl(url);
|
||||
const apiBase = gitHubApiBase(parsed.hostname);
|
||||
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
|
||||
let ref = pinnedRef;
|
||||
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||
).catch(() => {
|
||||
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
||||
});
|
||||
const allPaths = (tree.tree ?? [])
|
||||
.filter((entry) => entry.type === "blob")
|
||||
.map((entry) => entry.path)
|
||||
.filter((entry): entry is string => typeof entry === "string");
|
||||
const parsed = parseGitSourceUrl(url);
|
||||
const resolved = await resolveGitRef(parsed, authToken);
|
||||
const snapshot = await openRepoSnapshot(parsed, resolved.trackingRef, resolved.pinnedSha, authToken);
|
||||
const ref = snapshot.sha;
|
||||
const trackingRef = resolved.trackingRef;
|
||||
const allPaths = await snapshot.listFiles();
|
||||
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
||||
const scopedPaths = basePrefix
|
||||
? allPaths.filter((entry) => entry.startsWith(basePrefix))
|
||||
@@ -1088,13 +1014,13 @@ async function readUrlSkillImports(
|
||||
);
|
||||
if (skillPaths.length === 0) {
|
||||
throw unprocessable(
|
||||
"No SKILL.md files were found in the provided GitHub source.",
|
||||
"No SKILL.md files were found in the provided source.",
|
||||
);
|
||||
}
|
||||
const skills: ImportedSkill[] = [];
|
||||
for (const relativeSkillPath of skillPaths) {
|
||||
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
||||
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
|
||||
const markdown = await snapshot.readFile(repoSkillPath);
|
||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
||||
@@ -1148,15 +1074,15 @@ async function readUrlSkillImports(
|
||||
if (skills.length === 0) {
|
||||
throw unprocessable(
|
||||
requestedSkillSlug
|
||||
? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.`
|
||||
: "No SKILL.md files were found in the provided GitHub source.",
|
||||
? `Skill ${requestedSkillSlug} was not found in the provided source.`
|
||||
: "No SKILL.md files were found in the provided source.",
|
||||
);
|
||||
}
|
||||
return { skills, warnings };
|
||||
}
|
||||
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
const markdown = await fetchText(url);
|
||||
const markdown = await fetchPlainText(url);
|
||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||
const urlObj = new URL(url);
|
||||
const fileName = path.posix.basename(urlObj.pathname);
|
||||
@@ -1548,6 +1474,22 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
|
||||
export function companySkillService(db: Db) {
|
||||
const agents = agentService(db);
|
||||
const projects = projectService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
|
||||
async function resolveSkillAuthToken(
|
||||
companyId: string,
|
||||
skill: { metadata: Record<string, unknown> | null },
|
||||
): Promise<string | undefined> {
|
||||
const meta = skill.metadata;
|
||||
if (!meta) return undefined;
|
||||
const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : "";
|
||||
if (!secretId) return undefined;
|
||||
try {
|
||||
return await secretsSvc.resolveSecretValue(companyId, secretId, "latest");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBundledSkills(companyId: string) {
|
||||
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
||||
@@ -1765,8 +1707,18 @@ export function companySkillService(db: Db) {
|
||||
}
|
||||
|
||||
const hostname = asString(metadata.hostname) || "github.com";
|
||||
const apiBase = gitHubApiBase(hostname);
|
||||
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
|
||||
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||
const parsed: ParsedGitSource = {
|
||||
cloneUrl: `https://${hostname}/${owner}/${repo}.git`,
|
||||
hostname,
|
||||
owner,
|
||||
repo,
|
||||
ref: trackingRef,
|
||||
basePath: "",
|
||||
filePath: null,
|
||||
explicitRef: true,
|
||||
};
|
||||
const { pinnedSha: latestRef } = await resolveGitRef(parsed, authToken);
|
||||
return {
|
||||
supported: true,
|
||||
reason: null,
|
||||
@@ -1806,12 +1758,25 @@ export function companySkillService(db: Db) {
|
||||
const repo = asString(metadata.repo);
|
||||
const hostname = asString(metadata.hostname) || "github.com";
|
||||
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
|
||||
const trackingRef = asString(metadata.trackingRef);
|
||||
const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug);
|
||||
if (!owner || !repo) {
|
||||
throw unprocessable("Skill source metadata is incomplete.");
|
||||
}
|
||||
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
||||
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
|
||||
const parsedSource: ParsedGitSource = {
|
||||
cloneUrl: `https://${hostname}/${owner}/${repo}.git`,
|
||||
hostname,
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
basePath: repoSkillDir,
|
||||
filePath: null,
|
||||
explicitRef: true,
|
||||
};
|
||||
const snapshot: RepoSnapshot = await openRepoSnapshot(parsedSource, trackingRef ?? null, ref, authToken);
|
||||
content = await snapshot.readFile(repoPath);
|
||||
} else if (skill.sourceType === "url") {
|
||||
if (normalizedPath !== "SKILL.md") {
|
||||
throw notFound("This skill source only exposes SKILL.md");
|
||||
@@ -1928,7 +1893,8 @@ export function companySkillService(db: Db) {
|
||||
throw unprocessable("Skill source locator is missing.");
|
||||
}
|
||||
|
||||
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
|
||||
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken);
|
||||
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
|
||||
if (!matching) {
|
||||
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
||||
@@ -2103,6 +2069,28 @@ export function companySkillService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLocators = new Set<string>();
|
||||
for (const skill of acceptedSkills) {
|
||||
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") continue;
|
||||
const locator = skill.sourceLocator ?? "";
|
||||
if (locator) sourceLocators.add(locator);
|
||||
}
|
||||
for (const sourceLocator of sourceLocators) {
|
||||
try {
|
||||
const result = await readUrlSkillImports(companyId, sourceLocator, null);
|
||||
for (const nextSkill of result.skills) {
|
||||
if (acceptedSkills.some((s) => s.slug === nextSkill.slug)) continue;
|
||||
const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
|
||||
if (persisted) {
|
||||
imported.push(persisted);
|
||||
upsertAcceptedSkill(persisted);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
warnings.push(`Could not re-scan source ${sourceLocator} — skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scannedProjects: scannedProjectIds.size,
|
||||
scannedWorkspaces: scanTargets.length,
|
||||
@@ -2326,20 +2314,29 @@ export function companySkillService(db: Db) {
|
||||
const incomingOwner = asString(incomingMeta.owner);
|
||||
const incomingRepo = asString(incomingMeta.repo);
|
||||
const incomingKind = asString(incomingMeta.sourceKind);
|
||||
// Bundled skills are sourced from the server image and re-upserted by
|
||||
// ensureBundledSkills only. Never let a non-bundled import overwrite a
|
||||
// bundled row, regardless of which org/repo it claims to be from.
|
||||
if (
|
||||
existing
|
||||
&& existingMeta.sourceKind === "paperclip_bundled"
|
||||
&& incomingKind === "github"
|
||||
&& incomingOwner === "paperclipai"
|
||||
&& incomingRepo === "paperclip"
|
||||
&& incomingKind !== "paperclip_bundled"
|
||||
) {
|
||||
out.push(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve sourceAuthSecretId across re-imports of the same skill. Skip
|
||||
// bundled rows: they should never carry a PAT reference, and preserving
|
||||
// one across a bundled re-upsert would re-attach stale data.
|
||||
const metadata = {
|
||||
...(skill.metadata ?? {}),
|
||||
skillKey: skill.key,
|
||||
...(existing?.metadata
|
||||
&& existingMeta.sourceKind !== "paperclip_bundled"
|
||||
&& typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
|
||||
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
|
||||
: {}),
|
||||
};
|
||||
const values = {
|
||||
companyId,
|
||||
@@ -2375,7 +2372,7 @@ export function companySkillService(db: Db) {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
|
||||
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
const parsed = parseSkillImportSourceInput(source);
|
||||
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
||||
@@ -2385,7 +2382,7 @@ export function companySkillService(db: Db) {
|
||||
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
||||
warnings: parsed.warnings,
|
||||
}
|
||||
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
|
||||
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
|
||||
.then((result) => ({
|
||||
skills: result.skills,
|
||||
warnings: [...parsed.warnings, ...result.warnings],
|
||||
@@ -2412,6 +2409,35 @@ export function companySkillService(db: Db) {
|
||||
}
|
||||
}
|
||||
const imported = await upsertImportedSkills(companyId, filteredSkills);
|
||||
|
||||
if (authToken && imported.length > 0) {
|
||||
for (const skill of imported) {
|
||||
const skillMeta = skill.metadata as Record<string, unknown> | null;
|
||||
if (skillMeta?.sourceKind === "paperclip_bundled") continue;
|
||||
const secretName = `skill-pat:${skill.id}`;
|
||||
let secretId: string;
|
||||
const existing = await secretsSvc.getByName(companyId, secretName);
|
||||
if (existing) {
|
||||
await secretsSvc.rotate(existing.id, { value: authToken });
|
||||
secretId = existing.id;
|
||||
} else {
|
||||
const created = await secretsSvc.create(companyId, {
|
||||
name: secretName,
|
||||
provider: "local_encrypted",
|
||||
value: authToken,
|
||||
description: `PAT for skill ${skill.slug}`,
|
||||
});
|
||||
secretId = created.id;
|
||||
}
|
||||
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
||||
meta.sourceAuthSecretId = secretId;
|
||||
await db
|
||||
.update(companySkills)
|
||||
.set({ metadata: meta, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId)));
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, warnings };
|
||||
}
|
||||
|
||||
@@ -2451,9 +2477,85 @@ export function companySkillService(db: Db) {
|
||||
// Clean up materialized runtime files
|
||||
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
||||
|
||||
const meta = skill.metadata as Record<string, unknown> | null;
|
||||
const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
|
||||
if (secretId) {
|
||||
// Skip cleanup if another skill in the same company still references this
|
||||
// secret. The deleted row is already gone from the table, so any result
|
||||
// here is a sibling skill we shouldn't orphan.
|
||||
const otherSkillRefs = await db
|
||||
.select({ id: companySkills.id })
|
||||
.from(companySkills)
|
||||
.where(and(
|
||||
eq(companySkills.companyId, companyId),
|
||||
sql`${companySkills.metadata} ->> 'sourceAuthSecretId' = ${secretId}`,
|
||||
))
|
||||
.limit(1);
|
||||
if (otherSkillRefs.length === 0) {
|
||||
try {
|
||||
await secretsSvc.remove(secretId);
|
||||
} catch {
|
||||
// Best-effort: don't fail the skill deletion if secret cleanup fails
|
||||
// (typically blocked by an agent env binding still referencing it).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
async function updateSkillAuth(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
authToken: string | null,
|
||||
): Promise<CompanySkill | null> {
|
||||
const skill = await getById(companyId, skillId);
|
||||
if (!skill) return null;
|
||||
|
||||
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
||||
if (meta.sourceKind === "paperclip_bundled") {
|
||||
throw unprocessable("Cannot configure auth for bundled paperclip skills");
|
||||
}
|
||||
const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
|
||||
|
||||
if (authToken) {
|
||||
const secretName = `skill-pat:${skill.id}`;
|
||||
let secretId: string;
|
||||
const existingSecret = existingSecretId
|
||||
? await secretsSvc.getById(existingSecretId)
|
||||
: await secretsSvc.getByName(companyId, secretName);
|
||||
if (existingSecret) {
|
||||
await secretsSvc.rotate(existingSecret.id, { value: authToken });
|
||||
secretId = existingSecret.id;
|
||||
} else {
|
||||
const created = await secretsSvc.create(companyId, {
|
||||
name: secretName,
|
||||
provider: "local_encrypted",
|
||||
value: authToken,
|
||||
description: `PAT for skill ${skill.slug}`,
|
||||
});
|
||||
secretId = created.id;
|
||||
}
|
||||
meta.sourceAuthSecretId = secretId;
|
||||
} else {
|
||||
if (existingSecretId) {
|
||||
try {
|
||||
await secretsSvc.remove(existingSecretId);
|
||||
} catch {
|
||||
// Best-effort: don't fail the metadata update if secret deletion fails
|
||||
}
|
||||
}
|
||||
delete meta.sourceAuthSecretId;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(companySkills)
|
||||
.set({ metadata: meta, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
|
||||
.returning();
|
||||
return updated ? toCompanySkill(updated) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
listFull,
|
||||
@@ -2470,6 +2572,7 @@ export function companySkillService(db: Db) {
|
||||
createLocalSkill,
|
||||
deleteSkill,
|
||||
importFromSource,
|
||||
updateSkillAuth,
|
||||
scanProjectWorkspaces,
|
||||
importPackageFiles,
|
||||
installUpdate,
|
||||
|
||||
@@ -206,6 +206,7 @@ export function environmentRunOrchestrator(
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
agentId: string;
|
||||
heartbeatRunId: string;
|
||||
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
||||
}): Promise<EnvironmentRuntimeLeaseRecord> {
|
||||
@@ -280,6 +281,7 @@ export function environmentRunOrchestrator(
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
issueId: input.issueId,
|
||||
agentId: input.agentId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
persistedExecutionWorkspace: input.persistedExecutionWorkspace,
|
||||
});
|
||||
|
||||
@@ -103,6 +103,14 @@ export interface EnvironmentDriverAcquireInput {
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
/**
|
||||
* UUID of the owning agent. Null for ad-hoc invocations (e.g.
|
||||
* operator-initiated `Test` probes) that are not tied to a specific agent.
|
||||
* Threaded through to plugin-backed sandbox providers so they can scope
|
||||
* lease state (PVCs, subdirs, etc.) per-agent without needing to look it
|
||||
* up via callback.
|
||||
*/
|
||||
agentId: string | null;
|
||||
/**
|
||||
* UUID of the owning heartbeat run, or null for ad-hoc invocations
|
||||
* (e.g. operator-initiated `Test` probes) that are not tied to a run.
|
||||
@@ -468,6 +476,7 @@ function createSandboxEnvironmentDriver(
|
||||
config: workerConfig,
|
||||
providerLeaseId: reusableLease.providerLeaseId,
|
||||
leaseMetadata: reusableLease.metadata ?? undefined,
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
},
|
||||
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
||||
).then((resumed) =>
|
||||
@@ -489,6 +498,7 @@ function createSandboxEnvironmentDriver(
|
||||
// UUID so providers that validate or persist the runId still see
|
||||
// a well-formed identifier.
|
||||
runId: input.heartbeatRunId ?? randomUUID(),
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||
},
|
||||
resolvePluginSandboxRpcTimeoutMs(workerConfig),
|
||||
@@ -897,6 +907,7 @@ function createPluginEnvironmentDriver(
|
||||
issueId: input.issueId,
|
||||
config: parsed.config.driverConfig,
|
||||
runId: input.heartbeatRunId ?? randomUUID(),
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
workspaceMode: input.executionWorkspaceMode ?? undefined,
|
||||
});
|
||||
|
||||
@@ -1110,6 +1121,11 @@ export function environmentRuntimeService(
|
||||
companyId: string;
|
||||
environment: Environment;
|
||||
issueId: string | null;
|
||||
/**
|
||||
* UUID of the owning agent. Null for ad-hoc invocations (e.g.
|
||||
* operator-initiated `Test` probes).
|
||||
*/
|
||||
agentId: string | null;
|
||||
/** Null for ad-hoc invocations (e.g. operator-initiated `Test` probes). */
|
||||
heartbeatRunId: string | null;
|
||||
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
||||
@@ -1126,6 +1142,7 @@ export function environmentRuntimeService(
|
||||
companyId: input.companyId,
|
||||
environment: input.environment,
|
||||
issueId: input.issueId,
|
||||
agentId: input.agentId,
|
||||
heartbeatRunId: input.heartbeatRunId,
|
||||
executionWorkspaceId: leaseContext.executionWorkspaceId,
|
||||
executionWorkspaceMode: leaseContext.executionWorkspaceMode,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import path from "path";
|
||||
import git from "isomorphic-git";
|
||||
import http from "isomorphic-git/http/node";
|
||||
import { Volume, createFsFromVolume } from "memfs";
|
||||
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
export type ParsedGitSource = {
|
||||
cloneUrl: string;
|
||||
hostname: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
ref: string | null;
|
||||
basePath: string;
|
||||
filePath: string | null;
|
||||
explicitRef: boolean;
|
||||
};
|
||||
|
||||
export type RefResolution = {
|
||||
pinnedSha: string;
|
||||
trackingRef: string | null;
|
||||
};
|
||||
|
||||
export type RepoSnapshot = {
|
||||
sha: string;
|
||||
listFiles(): Promise<string[]>;
|
||||
readFile(repoPath: string): Promise<string>;
|
||||
readFileOptional(repoPath: string): Promise<string | null>;
|
||||
readBinary(repoPath: string): Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
const SHA_REGEX = /^[0-9a-f]{40}$/i;
|
||||
|
||||
export function buildCloneUrl(hostname: string, owner: string, repo: string): string {
|
||||
return `https://${hostname}/${owner}/${repo}.git`;
|
||||
}
|
||||
|
||||
export function parseGitSourceUrl(rawUrl: string): ParsedGitSource {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw unprocessable("Invalid git source URL");
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw unprocessable("Source URL must use HTTPS");
|
||||
}
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) {
|
||||
throw unprocessable("Source URL must include an owner and repository");
|
||||
}
|
||||
const owner = segments[0]!;
|
||||
const repo = segments[1]!.replace(/\.git$/i, "");
|
||||
|
||||
// Query-string shape: /{owner}/{repo}?ref=...&path=...
|
||||
// Used by company portability URLs. Takes precedence over path-based parsing
|
||||
// so a URL with both shapes (rare) prefers the explicit query params.
|
||||
const queryRef = url.searchParams.get("ref")?.trim() ?? null;
|
||||
const queryPath = url.searchParams.get("path")?.trim() ?? null;
|
||||
if (queryRef || queryPath) {
|
||||
const normalizedPath = (queryPath ?? "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
return {
|
||||
cloneUrl: buildCloneUrl(url.hostname, owner, repo),
|
||||
hostname: url.hostname,
|
||||
owner,
|
||||
repo,
|
||||
ref: queryRef || null,
|
||||
basePath: normalizedPath,
|
||||
filePath: null,
|
||||
explicitRef: Boolean(queryRef),
|
||||
};
|
||||
}
|
||||
|
||||
let ref: string | null = null;
|
||||
let basePath = "";
|
||||
let filePath: string | null = null;
|
||||
let explicitRef = false;
|
||||
let tail: string[] = [];
|
||||
|
||||
// Recognise common host-specific URL shapes so users can paste a tree/blob link.
|
||||
if (segments[2] === "tree" || segments[2] === "blob") {
|
||||
// github.com style
|
||||
ref = segments[3] ?? null;
|
||||
tail = segments.slice(4);
|
||||
explicitRef = ref !== null;
|
||||
} else if (segments[2] === "src" && (segments[3] === "branch" || segments[3] === "commit" || segments[3] === "tag")) {
|
||||
// gitea / forgejo style
|
||||
ref = segments[4] ?? null;
|
||||
tail = segments.slice(5);
|
||||
explicitRef = ref !== null;
|
||||
} else if (segments[2] === "-" && (segments[3] === "tree" || segments[3] === "blob")) {
|
||||
// gitlab style: /{owner}/{repo}/-/tree/{ref}/{path}
|
||||
ref = segments[4] ?? null;
|
||||
tail = segments.slice(5);
|
||||
explicitRef = ref !== null;
|
||||
} else if (segments[2] === "src" && segments.length >= 4) {
|
||||
// bitbucket style: /{owner}/{repo}/src/{ref}/{path}
|
||||
ref = segments[3] ?? null;
|
||||
tail = segments.slice(4);
|
||||
explicitRef = ref !== null;
|
||||
}
|
||||
|
||||
if (segments[2] === "blob" || (segments[2] === "-" && segments[3] === "blob")) {
|
||||
const joined = tail.join("/");
|
||||
filePath = joined || null;
|
||||
basePath = filePath ? path.posix.dirname(filePath) : "";
|
||||
if (basePath === ".") basePath = "";
|
||||
} else if (tail.length > 0) {
|
||||
const joined = tail.join("/");
|
||||
// Heuristic: if the last segment looks like a file (has an extension), treat as file
|
||||
const last = tail[tail.length - 1]!;
|
||||
if (/\.[A-Za-z0-9]+$/.test(last)) {
|
||||
filePath = joined;
|
||||
basePath = path.posix.dirname(joined);
|
||||
if (basePath === ".") basePath = "";
|
||||
} else {
|
||||
basePath = joined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cloneUrl: buildCloneUrl(url.hostname, owner, repo),
|
||||
hostname: url.hostname,
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
basePath,
|
||||
filePath,
|
||||
explicitRef,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthCallback(authToken: string | undefined) {
|
||||
if (!authToken) return undefined;
|
||||
// Universal pattern: token-as-username works for GitHub PATs (classic and fine-grained),
|
||||
// GitLab project/personal access tokens, Gitea/Forgejo tokens, and Bitbucket app passwords
|
||||
// when used over the git smart-HTTP protocol.
|
||||
return () => ({ username: authToken, password: "x-oauth-basic" });
|
||||
}
|
||||
|
||||
async function withGitErrors<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (/HTTP Error: 401/i.test(message)) {
|
||||
throw unprocessable(`${label}: authentication required or token rejected`);
|
||||
}
|
||||
if (/HTTP Error: 403/i.test(message)) {
|
||||
throw unprocessable(`${label}: access forbidden`);
|
||||
}
|
||||
if (/HTTP Error: 404/i.test(message) || /repository not found/i.test(message)) {
|
||||
throw unprocessable(`${label}: repository not found`);
|
||||
}
|
||||
if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT/i.test(message)) {
|
||||
throw unprocessable(`${label}: could not connect to host`);
|
||||
}
|
||||
throw unprocessable(`${label}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGitRef(
|
||||
parsed: ParsedGitSource,
|
||||
authToken?: string,
|
||||
): Promise<RefResolution> {
|
||||
const onAuth = buildAuthCallback(authToken);
|
||||
|
||||
if (parsed.ref && SHA_REGEX.test(parsed.ref.trim())) {
|
||||
return {
|
||||
pinnedSha: parsed.ref.trim().toLowerCase(),
|
||||
trackingRef: parsed.explicitRef ? parsed.ref.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
const refs = await withGitErrors(`Resolve refs for ${parsed.cloneUrl}`, () =>
|
||||
git.listServerRefs({
|
||||
http,
|
||||
url: parsed.cloneUrl,
|
||||
onAuth,
|
||||
symrefs: true,
|
||||
protocolVersion: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const findExact = (fullRef: string) => refs.find((r) => r.ref === fullRef);
|
||||
|
||||
if (!parsed.ref) {
|
||||
const head = refs.find((r) => r.ref === "HEAD");
|
||||
if (!head?.oid) {
|
||||
throw unprocessable(`Could not determine default branch for ${parsed.cloneUrl}`);
|
||||
}
|
||||
const target = head.target?.replace(/^refs\/heads\//, "") ?? null;
|
||||
return { pinnedSha: head.oid, trackingRef: target };
|
||||
}
|
||||
|
||||
const wanted = parsed.ref.replace(/^refs\/(heads|tags)\//, "");
|
||||
const branch = findExact(`refs/heads/${wanted}`);
|
||||
if (branch?.oid) return { pinnedSha: branch.oid, trackingRef: wanted };
|
||||
|
||||
// Prefer the peeled (annotated) tag oid when present, else the tag object oid.
|
||||
const peeled = findExact(`refs/tags/${wanted}^{}`);
|
||||
if (peeled?.oid) return { pinnedSha: peeled.oid, trackingRef: wanted };
|
||||
const tag = findExact(`refs/tags/${wanted}`);
|
||||
if (tag?.oid) return { pinnedSha: tag.oid, trackingRef: wanted };
|
||||
|
||||
throw unprocessable(`Ref '${parsed.ref}' not found in ${parsed.cloneUrl}`);
|
||||
}
|
||||
|
||||
export async function openRepoSnapshot(
|
||||
parsed: ParsedGitSource,
|
||||
trackingRef: string | null,
|
||||
expectedSha: string,
|
||||
authToken?: string,
|
||||
): Promise<RepoSnapshot> {
|
||||
const volume = new Volume();
|
||||
const fs = createFsFromVolume(volume) as unknown as Parameters<typeof git.clone>[0]["fs"];
|
||||
const dir = "/repo";
|
||||
const onAuth = buildAuthCallback(authToken);
|
||||
|
||||
await withGitErrors(`Clone ${parsed.cloneUrl}`, async () => {
|
||||
await git.clone({
|
||||
fs,
|
||||
http,
|
||||
dir,
|
||||
url: parsed.cloneUrl,
|
||||
ref: trackingRef ?? expectedSha,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
noCheckout: true,
|
||||
onAuth,
|
||||
});
|
||||
});
|
||||
|
||||
// Re-resolve to the actual commit cloned. If upstream moved between resolveGitRef and
|
||||
// clone, we trust what we cloned (snapshot is self-consistent).
|
||||
const sha = await git.resolveRef({ fs, dir, ref: "HEAD" });
|
||||
|
||||
async function listFiles(): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
await git.walk({
|
||||
fs,
|
||||
dir,
|
||||
trees: [git.TREE({ ref: sha })],
|
||||
map: async (filepath, entries) => {
|
||||
if (filepath === ".") return;
|
||||
const entry = entries?.[0];
|
||||
if (!entry) return;
|
||||
const type = await entry.type();
|
||||
if (type === "blob") {
|
||||
out.push(filepath);
|
||||
}
|
||||
},
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readBinary(repoPath: string): Promise<Uint8Array> {
|
||||
const normalized = repoPath.replace(/^\/+/, "");
|
||||
const { blob } = await git.readBlob({ fs, dir, oid: sha, filepath: normalized });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function readFile(repoPath: string): Promise<string> {
|
||||
const blob = await readBinary(repoPath);
|
||||
return new TextDecoder("utf-8").decode(blob);
|
||||
}
|
||||
|
||||
async function readFileOptional(repoPath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(repoPath);
|
||||
} catch (err) {
|
||||
// isomorphic-git throws NotFoundError when the path is missing from the tree.
|
||||
const name = (err as { code?: string; name?: string } | null)?.code
|
||||
?? (err as { name?: string } | null)?.name
|
||||
?? "";
|
||||
if (/NotFound/i.test(name)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return { sha, listFiles, readFile, readFileOptional, readBinary };
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
function isGitHubDotCom(hostname: string) {
|
||||
const h = hostname.toLowerCase();
|
||||
return h === "github.com" || h === "www.github.com";
|
||||
}
|
||||
|
||||
export function gitHubApiBase(hostname: string) {
|
||||
return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`;
|
||||
}
|
||||
|
||||
export function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) {
|
||||
const p = filePath.replace(/^\/+/, "");
|
||||
return isGitHubDotCom(hostname)
|
||||
? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}`
|
||||
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
|
||||
}
|
||||
|
||||
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch {
|
||||
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,8 @@ import { logger } from "../middleware/logger.js";
|
||||
/** Default timeout for RPC calls in milliseconds. */
|
||||
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Hard upper bound for any RPC timeout (5 minutes). Prevents unbounded waits. */
|
||||
const MAX_RPC_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||
/** Hard upper bound for any RPC timeout (60 minutes). Prevents unbounded waits. */
|
||||
const MAX_RPC_TIMEOUT_MS = 60 * 60 * 1_000;
|
||||
|
||||
/** Timeout for the initialize RPC call. */
|
||||
const INITIALIZE_TIMEOUT_MS = 15_000;
|
||||
|
||||
@@ -36,10 +36,15 @@ export const companySkillsApi = {
|
||||
`/companies/${encodeURIComponent(companyId)}/skills`,
|
||||
payload,
|
||||
),
|
||||
importFromSource: (companyId: string, source: string) =>
|
||||
importFromSource: (companyId: string, source: string, authToken?: string) =>
|
||||
api.post<CompanySkillImportResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
||||
{ source },
|
||||
{ source, ...(authToken ? { authToken } : {}) },
|
||||
),
|
||||
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
|
||||
api.patch<CompanySkill>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
|
||||
{ authToken },
|
||||
),
|
||||
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
||||
api.post<CompanySkillProjectScanResult>(
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function withProviders(children: ReactNode) {
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("approvalLabel", () => {
|
||||
it("uses payload titles for generic board approvals", () => {
|
||||
expect(
|
||||
@@ -35,17 +55,19 @@ describe("ApprovalPayloadRenderer", () => {
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
recommendedAction: "Approve the frog reply.",
|
||||
nextActionOnApproval: "Post the frog comment on the issue.",
|
||||
risks: ["The frog might be too powerful."],
|
||||
proposedComment: "(o)<",
|
||||
}}
|
||||
/>,
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
recommendedAction: "Approve the frog reply.",
|
||||
nextActionOnApproval: "Post the frog comment on the issue.",
|
||||
risks: ["The frog might be too powerful."],
|
||||
proposedComment: "(o)<",
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,14 +89,16 @@ describe("ApprovalPayloadRenderer", () => {
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
hidePrimaryTitle
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
}}
|
||||
/>,
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
hidePrimaryTitle
|
||||
payload={{
|
||||
title: "Reply with an ASCII frog",
|
||||
summary: "Board asked for approval before posting the frog.",
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -86,3 +110,90 @@ describe("ApprovalPayloadRenderer", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BoardApprovalPayloadContent markdown rendering", () => {
|
||||
it("renders a ## header in summary as an h2 element", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ summary: "## Analysis\n\nThis is the summary." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<h2");
|
||||
expect(html).toContain("Analysis");
|
||||
expect(html).toContain("This is the summary.");
|
||||
});
|
||||
|
||||
it("renders a bulleted list in summary as ul and li elements", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ summary: "- Item one\n- Item two" }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<ul");
|
||||
expect(html).toContain("<li");
|
||||
expect(html).toContain("Item one");
|
||||
expect(html).toContain("Item two");
|
||||
});
|
||||
|
||||
it("renders a ## header in recommendedAction as an h2 element", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ recommendedAction: "## Approve\n\nApprove this action." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<h2");
|
||||
expect(html).toContain("Approve");
|
||||
expect(html).toContain("Approve this action.");
|
||||
});
|
||||
|
||||
it("renders a bulleted list in recommendedAction as ul and li elements", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ recommendedAction: "- Step one\n- Step two" }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("<ul");
|
||||
expect(html).toContain("<li");
|
||||
expect(html).toContain("Step one");
|
||||
});
|
||||
|
||||
it("renders plain prose summary without adding list or heading markup", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ summary: "This is a simple one-line summary." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("This is a simple one-line summary.");
|
||||
expect(html).not.toContain("<ul");
|
||||
expect(html).not.toContain("<h2");
|
||||
});
|
||||
|
||||
it("renders plain prose recommendedAction without markdown markup", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
withProviders(
|
||||
<ApprovalPayloadRenderer
|
||||
type="request_board_approval"
|
||||
payload={{ recommendedAction: "Approve the deployment." }}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(html).toContain("Approve the deployment.");
|
||||
expect(html).not.toContain("<ul");
|
||||
expect(html).not.toContain("<h2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
export const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
@@ -185,7 +186,7 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unkn
|
||||
{summary && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Summary</p>
|
||||
<p className="leading-6 text-foreground/90">{summary}</p>
|
||||
<MarkdownBody softBreaks>{summary}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
{recommendedAction && (
|
||||
@@ -193,13 +194,13 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record<string, unkn
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-amber-700 dark:text-amber-300">
|
||||
Recommended action
|
||||
</p>
|
||||
<p className="mt-1 leading-6 text-foreground">{recommendedAction}</p>
|
||||
<MarkdownBody softBreaks className="mt-1">{recommendedAction}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
{nextActionOnApproval && (
|
||||
<div className="rounded-lg border border-border/60 bg-background/60 px-3.5 py-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">On approval</p>
|
||||
<p className="mt-1 leading-6 text-foreground">{nextActionOnApproval}</p>
|
||||
<MarkdownBody softBreaks className="mt-1">{nextActionOnApproval}</MarkdownBody>
|
||||
</div>
|
||||
)}
|
||||
{risks.length > 0 && (
|
||||
|
||||
@@ -60,6 +60,7 @@ export function CompanySettingsSidebar() {
|
||||
icon={MonitorCog}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
<SidebarNavItem
|
||||
to="/company/settings/access"
|
||||
label="Access"
|
||||
@@ -68,7 +69,6 @@ export function CompanySettingsSidebar() {
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
|
||||
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
@@ -253,8 +254,14 @@ export function EnvVarEditor({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
<Textarea
|
||||
rows={1}
|
||||
className={cn(
|
||||
inputClass,
|
||||
"flex-[3] min-h-0 resize-none shadow-none",
|
||||
"h-8 overflow-hidden field-sizing-fixed",
|
||||
"focus:h-auto focus:overflow-auto focus:field-sizing-content",
|
||||
)}
|
||||
placeholder="value"
|
||||
value={row.plainValue}
|
||||
onChange={(event) => updateRow(index, { plainValue: event.target.value })}
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("CompanySettingsNav", () => {
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "environments", label: "Environments" },
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
{ value: "access", label: "Access" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router";
|
||||
const items = [
|
||||
{ value: "general", label: "General", href: "/company/settings" },
|
||||
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
|
||||
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
|
||||
{ value: "access", label: "Access", href: "/company/settings/access" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
] as const;
|
||||
@@ -24,6 +25,10 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
return "invites";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/secrets")) {
|
||||
return "secrets";
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@ import { authApi } from "../api/auth";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
@@ -603,6 +612,8 @@ export function CompanyExport() {
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
||||
const [treeSearch, setTreeSearch] = useState("");
|
||||
const [includeSecrets, setIncludeSecrets] = useState(false);
|
||||
const [secretsConfirmOpen, setSecretsConfirmOpen] = useState(false);
|
||||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||
const savedExpandedRef = useRef<Set<string> | null>(null);
|
||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
||||
@@ -731,6 +742,7 @@ export function CompanyExport() {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
selectedFiles: Array.from(checkedFiles).sort(),
|
||||
sidebarOrder,
|
||||
includeSecrets,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const resultCheckedFiles = new Set(Object.keys(result.files));
|
||||
@@ -945,6 +957,11 @@ export function CompanyExport() {
|
||||
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{includeSecrets && (
|
||||
<span className="rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-0.5 text-xs text-amber-500">
|
||||
Secrets included
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -974,6 +991,29 @@ export function CompanyExport() {
|
||||
<div className="border-b border-border px-4 py-3 shrink-0">
|
||||
<h2 className="text-base font-semibold">Package files</h2>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2.5 shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ToggleSwitch
|
||||
checked={includeSecrets}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSecretsConfirmOpen(true);
|
||||
} else {
|
||||
setIncludeSecrets(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors" onClick={() => {
|
||||
if (includeSecrets) {
|
||||
setIncludeSecrets(false);
|
||||
} else {
|
||||
setSecretsConfirmOpen(true);
|
||||
}
|
||||
}}>
|
||||
Include secrets
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border px-3 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
@@ -1015,6 +1055,26 @@ export function CompanyExport() {
|
||||
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secrets confirmation dialog */}
|
||||
<Dialog open={secretsConfirmOpen} onOpenChange={setSecretsConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Include secrets?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Secrets will be exported as plaintext in the package file. Handle the exported package with care.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setSecretsConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => { setIncludeSecrets(true); setSecretsConfirmOpen(false); }}>
|
||||
Include secrets
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -866,6 +866,13 @@ export function CompanyImport() {
|
||||
title: "Import complete",
|
||||
body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`,
|
||||
});
|
||||
if (result.warnings.some((w) => w.includes("could not be decrypted") || w.toLowerCase().includes("failed to create secret"))) {
|
||||
pushToast({
|
||||
tone: "warn",
|
||||
title: "Secrets import warning",
|
||||
body: "Some secrets could not be decrypted. Review warnings and recreate manually.",
|
||||
});
|
||||
}
|
||||
// Force a fresh dashboard load so newly imported agents are immediately visible.
|
||||
window.location.assign(`/${importedCompany.issuePrefix}/dashboard`);
|
||||
},
|
||||
@@ -1309,6 +1316,18 @@ export function CompanyImport() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secrets info */}
|
||||
{importPreview.manifest.secrets && importPreview.manifest.secrets.length > 0 && (
|
||||
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<div className="text-xs font-medium text-amber-500 mb-1">Secrets to import</div>
|
||||
{importPreview.manifest.secrets.map((s) => (
|
||||
<div key={s.name} className="text-xs text-amber-500">
|
||||
{s.name}{s.provider !== "local_encrypted" ? ` (${s.provider})` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{importPreview.errors.length > 0 && (
|
||||
<div className="mx-5 mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3">
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -496,6 +497,103 @@ function SkillList({
|
||||
);
|
||||
}
|
||||
|
||||
function SkillAuthSection({
|
||||
companyId,
|
||||
skillId,
|
||||
hasAuth,
|
||||
}: {
|
||||
companyId: string;
|
||||
skillId: string;
|
||||
hasAuth: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
|
||||
const updateAuth = useMutation({
|
||||
mutationFn: (authToken: string | null) =>
|
||||
companySkillsApi.updateAuth(companyId, skillId, authToken),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) });
|
||||
setEditing(false);
|
||||
setToken("");
|
||||
pushToast({ tone: "success", title: "Auth updated" });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Failed to update auth",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
|
||||
{!editing ? (
|
||||
<>
|
||||
{hasAuth ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||
PAT configured
|
||||
</Button>
|
||||
<button
|
||||
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
onClick={() => updateAuth.mutate(null)}
|
||||
disabled={updateAuth.isPending}
|
||||
title="Remove PAT"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Add PAT
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="GitHub Personal Access Token"
|
||||
className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateAuth.mutate(token.trim())}
|
||||
disabled={!token.trim() || updateAuth.isPending}
|
||||
>
|
||||
{updateAuth.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setEditing(false); setToken(""); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillPane({
|
||||
loading,
|
||||
detail,
|
||||
@@ -630,6 +728,13 @@ function SkillPane({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
|
||||
<SkillAuthSection
|
||||
companyId={detail.companyId}
|
||||
skillId={detail.id}
|
||||
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
|
||||
/>
|
||||
)}
|
||||
{detail.sourceType === "github" && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
||||
@@ -778,6 +883,7 @@ export function CompanySkills() {
|
||||
const { pushToast } = useToastActions();
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [importAuthToken, setImportAuthToken] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
|
||||
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
||||
@@ -903,7 +1009,8 @@ export function CompanySkills() {
|
||||
}
|
||||
|
||||
const importSkill = useMutation({
|
||||
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
|
||||
mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) =>
|
||||
companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken),
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
|
||||
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
|
||||
@@ -916,6 +1023,7 @@ export function CompanySkills() {
|
||||
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
|
||||
}
|
||||
setSource("");
|
||||
setImportAuthToken("");
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
@@ -1089,7 +1197,8 @@ export function CompanySkills() {
|
||||
setEmptySourceHelpOpen(true);
|
||||
return;
|
||||
}
|
||||
importSkill.mutate(trimmedSource);
|
||||
const token = importAuthToken.trim() || undefined;
|
||||
importSkill.mutate({ importSource: trimmedSource, authToken: token });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1236,6 +1345,18 @@ export function CompanySkills() {
|
||||
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
{source.trim().length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
|
||||
<input
|
||||
type="password"
|
||||
value={importAuthToken}
|
||||
onChange={(event) => setImportAuthToken(event.target.value)}
|
||||
placeholder="Personal access token (optional, for private repos)"
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{scanStatusMessage && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{scanStatusMessage}
|
||||
|
||||
Reference in New Issue
Block a user