84 Commits

Author SHA1 Message Date
Chris Farhood 0c5f316f87 fix(ui): pull upstream skill UI to match new shared types
Build: Production / build (push) Successful in 5m51s
Bumping shared CompanySkillListItem and CatalogSkill types in the
previous commits without updating the UI consumers broke the storybook
mocks (missing catalogKind, originHash, packageName, packageVersion)
and the CompanySkills page (missing queryKeys.companySkills.catalog
family). Replace the affected UI files with upstream/master:
  - ui/src/api/companySkills.ts
  - ui/src/pages/CompanySkills.tsx
  - ui/src/lib/queryKeys.ts
  - ui/storybook/stories/acpx-local.stories.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:45:08 -04:00
Chris Farhood 3620aaaad1 fix(dockerfile): copy packages/skills-catalog into deps stage
Build: Production / build (push) Failing after 1m58s
The previous commit pulled upstream's skills-catalog workspace package
but the Dockerfile's deps stage didn't include it in the COPY list, so
pnpm install --frozen-lockfile failed in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 20:49:48 -04:00
Chris Farhood 548d958f18 fix(skills): pull upstream skill runtime resolution to stop event-loop starvation
Build: Production / build (push) Failing after 12m39s
The fork's listRuntimeSkillEntries rematerialized every skill's files from
the DB on every heartbeat run dispatch — fs.rm + fs.mkdir + per-file
readFile/writeFile, sequentially per skill. With 24 configured skills and
5 concurrent agents, this saturated the Node event loop badly enough that
executeRun continuations couldn't reach activeRunExecutions.add() within
the orphan-reaper's 5-min threshold, causing reaper to false-positive runs
as "process_lost".

Upstream's listRuntimeSkillEntries calls resolveRuntimeSkillSource, which
checks if the materialized directory already exists on disk and short-
circuits when it does. Fixes the symptom at the root.

Replaces these files with upstream/master content:
  - server/src/services/company-skills.ts
  - server/src/services/heartbeat.ts
  - server/src/services/workspace-runtime.ts
  - server/src/services/company-portability.ts
  - server/src/routes/company-skills.ts
  - server/src/routes/agents.ts
  - packages/adapter-utils/src/server-utils.ts

Pulls in supporting upstream files:
  - server/src/services/catalog-provenance.ts
  - server/src/services/skills-catalog.ts
  - server/src/services/github-fetch.ts
  - server/src/services/portable-path.ts
  - packages/skills-catalog/ (new package)
  - packages/db document_annotation_* schema + migration 0091
  - packages/shared document-annotation types/validators

Drops fork features (to be re-evaluated later):
  - Gitea/Forgejo git skill sources (server/src/services/git-source.ts deleted)
  - PAT support for private skill repos
  - Fork-specific secret-export portability extensions

Adds agentId: null to acquireRunLease test-probe call in routes/agents.ts
to satisfy the fork's environment-runtime agentId requirement (kept).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 09:26:51 -04:00
Chris Farhood 562693197a ci(dev): apply same registry-auth and tag-pattern fixes as build-prod
Build: Production / build (push) Successful in 11m26s
Build: Dev / build (push) Successful in 12m24s
Build: Dev / update-infra (push) Successful in 2s
- username: admin (was gitea.repository_owner — the org name, which fails
  Gitea's per-scope token exchange during buildkit blob HEAD requests)
- :latest only on semver tag pushes (was every push to dev — dev pushes
  don't carry semver tags so :latest just won't be re-emitted, which is
  the right behavior for SHA-tracked dev deploys)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:39:00 -04:00
Chris Farhood bedeac5400 fix(dockerfile): sync deps stage with upstream + add mmx-cli
Build: Production / build (push) Successful in 3m19s
Upstream added grok-local adapter and plugin-llm-wiki/plugin-workspace-diff
packages along with a link-plugin-dev-sdk.mjs script, but the fork's
.farhoodlabs/Dockerfile deps stage didn't get the corresponding COPY
lines. Without grok-local's package.json present during
`pnpm install --frozen-lockfile`, the workspace symlinks for grok-local
weren't created, so UI tsc failed to resolve @paperclipai/adapter-utils
from grok-local/src/ui/*.

Also adding mmx-cli (MiniMax multimodal CLI) as its own npm install
line in the production stage so the upstream-managed npm install line
above stays diff-clean against upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:03:05 -04:00
Chris Farhood 06abed51b7 fix(lockfile): regenerate pnpm-lock to match merged package.json
Build: Production / build (push) Failing after 2m32s
The lockfile after the master merge was missing isomorphic-git, memfs,
and likely other deps added on the master side. `pnpm install
--frozen-lockfile` (used in the Dockerfile) rejected it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:21:03 -04:00
Chris Farhood dcdc1d2353 Merge upstream/master (53 commits) into local
Build: Production / build (push) Failing after 13m4s
Resolved conflicts:
- ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items
- ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`)
- server plugin-worker-manager.ts: take master's 15min RPC timeout cap
- pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:01:31 -04:00
Chris Farhood b172b6a319 ci: log into registry as token owner (admin), not org name
Build: Production / build (push) Successful in 6m41s
Gitea's docker login is lenient and accepts the org name as a username
at handshake time, but the per-scope token exchange that buildkit
performs for blob operations needs to resolve a real user identity.
Using admin (the user that owns REGISTRY_TOKEN) prevents 401s on
blob HEAD requests during push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:28:10 -04:00
Chris Farhood 39790922f1 ci: emit :latest only on semver tags, not every commit
Build: Production / build (push) Failing after 3m2s
Tagging :latest on every push to local made every CI run try to
overwrite the existing :latest manifest. The Gitea registry rejected
the overwrite, causing builds to fail. Branch builds now produce
only the immutable SHA tag; :latest is reserved for vX.Y.Z tag pushes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:09:16 -04:00
Chris Farhood ebc249d0b3 docs: fix overlay location description in CLAUDE.md
Build: Production / build (push) Failing after 10m22s
The overlay lives on dev (canonical) and local, not master.
Master is now a pure mirror of upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:43:46 -04:00
Chris Farhood 5499a0b4a6 ci: adapt workflows for Gitea migration
Build: Production / build (push) Successful in 5m39s
Change runner from runners-farhoodlabs to ubuntu-latest across all fork
workflows. Update container registry from ghcr.io to git.farh.net and
authenticate with REGISTRY_TOKEN. Migrate update-infra API calls from
GitHub to Gitea. Disable refresh-lockfile.yml (requires GitHub gh CLI).
Update CLAUDE.md references.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 11:17:45 +00:00
Chris Farhood 55faea456f Merge pull request #16 from farhoodlabs/dev
Dev
2026-05-16 08:38:38 -07:00
Chris Farhood 329ba3fd2e Merge pull request #15 from farhoodlabs/feat/portability-git-backend-agnostic
refactor(portability): migrate to git-source; delete github-fetch.ts
2026-05-16 07:43:35 -07:00
Chris Farhood bf251188df test(portability): cover resolveSource orchestration via previewImport
Closes the coverage gap on the actual migrated function. Mocks the
two network-touching git-source exports (resolveGitRef, openRepoSnapshot)
while keeping parseGitSourceUrl real so the parseGitHubSourceUrl shim
contract stays honest. Adds 5 cases:

- happy path: opens one snapshot, calls listFiles, readFileOptional
  on COMPANY.md, readFile on candidate paths
- ref fallback: when openRepoSnapshot('main') rejects, falls back to
  'master' and emits the expected warning
- COMPANY.md absent everywhere: throws "missing COMPANY.md"
- referenced logo: readBinary is called for the logoPath from
  .paperclip.yaml
- logo read failure: warning emitted, no throw

57/57 portability tests passing; existing 52 unchanged via shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:35:56 -04:00
Chris Farhood 80f7d8270c refactor(portability): migrate to git-source; delete github-fetch.ts
Mirrors the skills refactor: company-portability was the second user of
the per-host REST shim (its own parallel parseGitHubSourceUrl + fetch
helpers + raw.githubusercontent URL builder), so importing a company
package from a non-github URL hit the same Gitea 404 the skills path did.

- Extend git-source.ts:
  - parseGitSourceUrl: also recognises query-string shape
    (?ref=...&path=...) used by portability URLs, with precedence over
    path-style segments when both are present.
  - RepoSnapshot: add readBinary (Uint8Array for the company logo
    fetch) and readFileOptional (null on NotFoundError, for the
    COMPANY.md probe + main->master fallback).
- Rewrite resolveSource in company-portability.ts to open a single
  in-memory snapshot per import and serve all reads (COMPANY.md,
  candidate tree, includes, logo) from it. Drops fetchText/fetchJson/
  fetchBinary/fetchOptionalText.
- parseGitHubSourceUrl stays exported with its original return shape
  ({hostname, owner, repo, ref, basePath, companyPath}) so the existing
  test suite passes unchanged. It now delegates URL parsing to
  parseGitSourceUrl and layers companyPath derivation on top.
- Delete server/src/services/github-fetch.ts: zero remaining callers.

Test coverage:
- 7 new git-source tests (query-string parse variants, query-string
  precedence over path style, readBinary, readFileOptional NotFound
  null + non-NotFound rethrow) — 34/34 passing.
- 52 existing company-portability tests still pass via the
  parseGitHubSourceUrl shim contract.
- Smoke-tested end-to-end against https://git.farh.net/.../?ref=main:
  ref resolves, snapshot opens, readFile/readBinary/readFileOptional
  all return expected results.

Note: two pre-existing failures in company-skills-routes.test.ts
("does not expose a skill reference...") exist on dev too and are
unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:28:22 -04:00
Chris Farhood 5703fa225c Merge dev into local; drop dead assemble-local workflow
- Resolves the duplicate-SHA conflict on the gitea/skills commits
  by taking dev's versions (canonical after PR #13 superseded the
  original shim with the git-source refactor).
- Deletes .github/workflows/assemble-local.yml -- the workflow
  triggered on master push but lived on local, so it never fired
  automatically; promotion happens via dev->local PRs instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:16:28 -04:00
Chris Farhood 4317d2a3b4 Merge pull request #13 from farhoodlabs/feat/skills-git-backend-agnostic
refactor(skills): backend-agnostic git via wire protocol (fixes Gitea/Forgejo)
2026-05-16 06:51:22 -07:00
Chris Farhood d30afdb1b2 test(skills): add vitest coverage for git-source module
27 tests covering the surface that had none:

- parseGitSourceUrl: bare URLs (github/gitea/gitlab), tree/blob/src
  shapes, subpaths, file paths, trailing .git stripping, https-only
  enforcement, malformed/missing-segment rejection.
- resolveGitRef: 40-hex SHA passthrough (no network call), default
  branch via HEAD symref, named branch, peeled annotated tag, lightweight
  tag, ref-not-found, network/401/404 error translation, onAuth
  callback shape (token-as-username, x-oauth-basic) and absence.
- openRepoSnapshot: clone args (singleBranch/depth=1/noCheckout),
  tree walk filtering trees vs blobs, readFile path, SHA fallback
  when tracking ref is null, 404 translation.

Mocks at the isomorphic-git boundary; verifies our adaptation logic,
not isomorphic-git itself.

Known limit surfaced by a test (not fixed here): gitea URLs with
slash-containing branch names like /src/branch/feature/x are
ambiguous without server-side disambiguation. The test uses a
single-segment branch; the multi-segment case needs a separate fix
(refCandidates from longest-to-shortest, resolved against
listServerRefs output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:36:27 -04:00
Chris Farhood 0fd4e9c4d1 refactor(skills): replace per-host REST shims with git wire protocol
The skill import/update/file-read pipeline talked to host-specific REST
APIs (GitHub /commits/{ref}, /git/trees/{sha}, raw.githubusercontent.com)
and the recent Gitea support was a parallel shim on top of the same
pattern. The result was multiple ref-resolution shapes that needed
per-host branching, and on Gitea the /commits/{ref} endpoint returns
404 outright -- so even public Gitea/Forgejo repos failed to import.

Replace with a single git-source module backed by isomorphic-git +
memfs. It speaks the smart-HTTP protocol any sane git server already
serves:

- resolveGitRef: one listServerRefs call, no host API. Handles default
  branch (symref on HEAD), named branches, annotated/lightweight tags,
  and SHA passthrough.
- openRepoSnapshot: shallow singleBranch clone into an in-memory fs;
  listFiles via git.walk, readFile via git.readBlob. No tempdirs, no
  execFile, no per-host endpoints.
- Universal auth via onAuth (token-as-username) covering GitHub PATs,
  GitLab PATs, Gitea/Forgejo tokens.
- parseGitSourceUrl recognises github tree/blob, gitea src/branch|
  commit|tag, gitlab /-/tree, bitbucket /src/{ref} URL shapes plus
  bare clone URLs.

Stored skill metadata is unchanged (hostname/owner/repo/ref/trackingRef/
repoSkillDir), so existing rows keep working -- the clone URL is
derived at fetch time.

company-portability.ts still imports github-fetch.ts (same broken
pattern, separate feature). Left as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:16:00 -04:00
Chris Farhood 8dbe99e32e feat(skills): support Gitea/Forgejo git hosts end-to-end
The skills source pipeline was hardcoded to GitHub conventions, so even
though the UI now accepts non-GitHub URLs, the server couldn't actually
fetch from anywhere else.

- github-fetch.ts: dispatch by host family (github.com → GitHub API +
  raw.githubusercontent.com; everything else → Gitea/Forgejo API v1 +
  /api/v1/repos/.../media for raw content).
- parseGitHubSourceUrl: also accept Gitea/Forgejo web URLs
  (/{owner}/{repo}/src/{branch|commit|tag}/{ref}/{path}).
- routes/company-skills.ts: drop the hostname='github.com' gate in
  deriveTrackedSkillRef so non-GitHub skills are still tracked.
- Generalize user-facing strings ('GitHub PAT' → 'PAT', 'GitHub source URL'
  → 'Source URL', etc.).

GitHub Enterprise (was assumed by '/api/v3') is no longer a special case —
non-github.com hosts are treated as Gitea/Forgejo. If GHE support is needed
later, add a per-source host-family override.
2026-05-14 11:49:51 -04:00
Chris Farhood 818a8eade8 feat(skills): support Gitea/Forgejo git hosts end-to-end
The skills source pipeline was hardcoded to GitHub conventions, so even
though the UI now accepts non-GitHub URLs, the server couldn't actually
fetch from anywhere else.

- github-fetch.ts: dispatch by host family (github.com → GitHub API +
  raw.githubusercontent.com; everything else → Gitea/Forgejo API v1 +
  /api/v1/repos/.../media for raw content).
- parseGitHubSourceUrl: also accept Gitea/Forgejo web URLs
  (/{owner}/{repo}/src/{branch|commit|tag}/{ref}/{path}).
- routes/company-skills.ts: drop the hostname='github.com' gate in
  deriveTrackedSkillRef so non-GitHub skills are still tracked.
- Generalize user-facing strings ('GitHub PAT' → 'PAT', 'GitHub source URL'
  → 'Source URL', etc.).

GitHub Enterprise (was assumed by '/api/v3') is no longer a special case —
non-github.com hosts are treated as Gitea/Forgejo. If GHE support is needed
later, add a per-source host-family override.
2026-05-14 11:49:49 -04:00
Chris Farhood 9e854e33d9 fix(skills): drop GitHub-only regex gate on PAT input
The PAT input on the skill import flow was hidden by a regex that matched
github.com or org/repo shorthand. Self-hosted Gitea/Forgejo/GitLab sources
got no auth field at all. Always show the input when a source is entered,
and label it generically ('Personal access token') instead of 'GitHub PAT'.

UI only — backend already accepts any token via /skills/:id/auth and
/companies/:companyId/skills POST {source, authToken}.
2026-05-14 11:41:40 -04:00
Chris Farhood 26e814a426 fix(skills): drop GitHub-only regex gate on PAT input
The PAT input on the skill import flow was hidden by a regex that matched
github.com or org/repo shorthand. Self-hosted Gitea/Forgejo/GitLab sources
got no auth field at all. Always show the input when a source is entered,
and label it generically ('Personal access token') instead of 'GitHub PAT'.

UI only — backend already accepts any token via /skills/:id/auth and
/companies/:companyId/skills POST {source, authToken}.
2026-05-14 11:41:39 -04:00
Chris Farhood fccbc7e39e feat(ci): install gitea tea CLI in fork Dockerfile
Adds the official Gitea 'tea' CLI (v0.14.0) alongside the existing forgejo
CLIs (fj, fj-ex, fgj). Useful when interacting with Gitea instances whose API
surface is covered by tea but not by the forgejo variants.
2026-05-14 10:04:18 -04:00
Chris Farhood 729ef021e9 feat(ci): install gitea tea CLI in fork Dockerfile
Adds the official Gitea 'tea' CLI (v0.14.0) alongside the existing forgejo
CLIs (fj, fj-ex, fgj). Useful when interacting with Gitea instances whose API
surface is covered by tea but not by the forgejo variants.
2026-05-14 10:03:30 -04:00
Dotta 9b275c332a fix(plugin): fail fast on upload protocol drift 2026-05-13 22:35:26 -04:00
Dotta 9035b70aa9 fix(plugin): close timed out kubernetes exec sockets 2026-05-13 22:35:26 -04:00
Dotta 1eccb71213 fix(plugin): guard kubernetes upload edge cases 2026-05-13 22:35:26 -04:00
Dotta f8b8303089 fix(plugin): harden kubernetes exec upload parsing 2026-05-13 22:35:26 -04:00
Dotta 3e998bda97 fix(plugin): close kubernetes exec timeout edges 2026-05-13 22:35:26 -04:00
Dotta 40e8638aa3 fix(plugin): harden kubernetes fast upload edges 2026-05-13 22:35:26 -04:00
Dotta 713fb6eb4e fix(plugin): share kubernetes shell quoting helper 2026-05-13 22:35:26 -04:00
Dotta 58d1b19206 fix(plugin): address kubernetes fast upload review 2026-05-13 22:35:26 -04:00
Dotta fcbbd50b60 feat(plugin): add kubernetes fast upload interceptor 2026-05-13 22:35:26 -04:00
Dotta a6c2e0392b fix(plugin): address kubernetes greptile follow-up
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-13 22:35:26 -04:00
Dotta a98c5cdfa9 fix(plugin): warn on missing kubernetes adapter env 2026-05-13 22:35:26 -04:00
Dotta 94fc81266f fix(plugin): reconcile kubernetes namespace labels 2026-05-13 22:35:26 -04:00
Dotta b248acd46c fix(plugin): align kubernetes config validation 2026-05-13 22:35:26 -04:00
Dotta c37e5919ce fix(plugin): restrict kubernetes cilium cidr egress 2026-05-13 22:35:26 -04:00
Dotta 45621aac53 fix(plugin): address kubernetes greptile timeouts 2026-05-13 22:35:26 -04:00
Dotta 39d81c732c fix(plugin): bound kubernetes sandbox execution 2026-05-13 22:35:26 -04:00
Dotta e691d30d12 fix(plugin): harden kubernetes sandbox orchestration 2026-05-13 22:35:26 -04:00
Dotta 163e3ca1a5 feat(plugin): add kubernetes sandbox provider 2026-05-13 22:35:26 -04:00
Chris Farhood 55d6c5bfa4 Merge upstream/master into dev (13 commits — includes #5922, #5938, blocked inbox, recovery actions) 2026-05-13 22:35:18 -04:00
Chris Farhood b6b81f2f06 Merge updated feat/plugin-acquire-lease-agent-id into dev (adds tests) 2026-05-13 18:54:32 -04:00
Chris Farhood 4c4eeaba2b test: cover agentId threading on plugin lease RPCs and call sites
Adds focused tests for every code path the agentId addition touches:

- environment-runtime.test.ts (4 new tests):
  - plugin-driver acquireLease forwards agentId in RPC payload when present
  - plugin-driver acquireLease omits agentId from RPC payload when null
  - sandbox-provider acquireLease forwards agentId when present
  - sandbox-provider resumeLease forwards agentId when reuseLease=true matches
  - seedEnvironment helper now exposes the seeded agentId

- environment-run-orchestrator.test.ts (2 new tests):
  - acquireForRun threads agentId through to runtime.acquireRunLease
  - logActivity records the same agentId on environment.lease_acquired
  - new vi.hoisted mocks for environmentService.getById + ensureLocalEnvironment

- agent-test-environment-routes.test.ts (1 new assertion):
  - ad-hoc operator test-environment probe calls acquireRunLease with
    agentId: null and heartbeatRunId: null (no agent context)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:52:28 -04:00
Chris Farhood 7a8afbb719 Merge pull request #12 from farhoodlabs/dev
Dev
2026-05-12 16:39:28 -07:00
Chris Farhood b61455373c Merge updated feat/plugin-acquire-lease-agent-id into dev (adds resumeLease agentId) 2026-05-12 07:36:19 -04:00
Chris Farhood 73f4685729 feat(plugin-sdk): also thread agentId into environmentResumeLease params
Symmetric with the acquireLease change. Lets plugin-backed sandbox
providers reject a reusable lease whose stored agentId doesn't match
the current run's agent, forcing the host to acquire a fresh lease
instead of stomping the previous agent's workspace state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:36:08 -04:00
Chris Farhood 7cee02ddf3 Merge branch 'feat/plugin-acquire-lease-agent-id' into dev
Thread agentId into PluginEnvironmentAcquireLeaseParams + host call sites
so plugin-backed sandbox providers (e.g. paperclip-plugin-k8s) can scope
lease state per-agent without needing an SDK callback or DB lookup.
2026-05-12 07:34:00 -04:00
Chris Farhood 417782a6ec feat(plugin-sdk): thread agentId into environmentAcquireLease params
Add an optional agentId field to PluginEnvironmentAcquireLeaseParams and
thread it through the host's environment-runtime + run-orchestrator call
sites so plugin-backed sandbox providers can scope lease state (subdirs,
PVCs, etc.) per agent without an SDK callback or DB lookup.

The field is required-but-nullable on the internal EnvironmentDriverAcquireInput
(string | null) so every call site has to think about whether it has an
agent context. Ad-hoc operator probes (agent test-environment route)
pass null. The plugin RPC payload omits the field entirely when null,
keeping wire compatibility with older plugin worker SDKs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:33:10 -04:00
Chris Farhood 30ef61bb25 Merge pull request #11 from farhoodlabs/dev
Dev
2026-05-11 17:02:31 -07:00
Chris Farhood 872dd664ed revert(secrets): drop fork's usages-tracking + delete guard
Now that upstream's #5429 provides provider-based secrets management with formal
bindings (companySecretBindings table populated at config-save time) plus the
/secrets/:id/usage endpoint backed by listBindingReferences(), the fork's
parallel usages() scan is redundant for agent/routine bindings.

The fork's scan did cover one path upstream doesn't track: skill metadata
sourceAuthSecretId references. Dropping this means accidental deletion of a
skill's auth secret is no longer rejected — accepted as a chase-upstream tradeoff.

- server/src/services/secrets.ts: drop usages(), SecretUsage* types, in-use guard
  in remove(), and companySkills/agentService imports
- server/src/routes/secrets.ts: drop GET /secrets/:id/usages route
- ui/src/api/secrets.ts: drop usages() client method

Typechecks clean on server and ui.
2026-05-11 19:38:56 -04:00
Chris Farhood d9d9bbcf06 fix(secrets): drop fork's CompanySecrets page and dedupe sidebar/route
The merge of upstream's #5429 (provider vaults + AWS Secrets Manager) added a
new 2,155-line Secrets.tsx page covering everything the fork's 528-line
CompanySecrets page did, plus vault management and AWS import. Auto-merge took
both sides' sidebar entry and route registration, so React Router picked the
fork's older page first and upstream's AWS UI never rendered.

- ui/src/App.tsx: drop CompanySecrets import + duplicate route at /company/settings/secrets
- ui/src/components/CompanySettingsSidebar.tsx: drop duplicate Secrets nav item
- ui/src/pages/CompanySecrets.tsx: delete (superseded)

Server-side delete guard (services/secrets.ts: refuse delete when secret is
referenced by agents or skills) is preserved and still enforced; upstream's
page surfaces the API error message in place of the fork's inline reference list.
2026-05-11 19:32:08 -04:00
Chris Farhood c2a49879d5 fix(ci): add cursor-cloud adapter package.json to fork Dockerfile deps stage
Upstream #5664 added the cursor-cloud adapter; the .farhoodlabs/Dockerfile
overlay was missing the deps-stage COPY for its package.json, so pnpm install
didn't wire it as a workspace package and the build stage failed type-checking
cursor-cloud's UI sources from ui's tsc -b.
2026-05-11 19:16:51 -04:00
Chris Farhood 5d0d076704 chore(docs): remove FAR-108 from pending PR list (k8s adapter dropped) 2026-05-11 18:02:15 -04:00
Chris Farhood 08dc3d9ff4 Merge upstream/master into dev (76 commits)
Resolved 5 conflicts:
- .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev)
- server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events
- server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard,
  layered before upstream's soft-delete + provider cleanup in remove()
- ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods

Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
2026-05-11 18:01:56 -04:00
Chris Farhood 37e0aac971 ci: build prod image from .farhoodlabs/Dockerfile
Pulls the prod image up to the same toolset as the dev image (kubectl,
kubeseal, uv/uvx, forgejo CLIs, nano, vim) without diverging the upstream
root Dockerfile. Both build-dev.yml and build-prod.yml now share the same
fork-overlay Dockerfile; only the image tag and trigger branch differ.
2026-05-03 15:38:18 -04:00
Chris Farhood cee1cd7f4e Merge branches 'feat/skills-gitops-complete' and 'feat/secrets-management-ui' into local 2026-05-03 11:02:47 -04:00
Chris Farhood b3e7dcaa83 Merge branches 'feat/skills-gitops-complete' and 'feat/secrets-management-ui' into dev 2026-05-03 11:02:25 -04:00
Chris Farhood 3ea3020a76 fix(secrets): include skill metadata references in usages, route, and delete dialog
secretsSvc.usages() previously only scanned agent env bindings. Skills can
reference a secret via metadata.sourceAuthSecretId (set by the PAT auth
feature), so removing a secret without checking those references could orphan
a sibling skill's PAT pointer.

- Extend usages() to return { agents, skills } with both reference kinds.
- Update remove() to block when either array is non-empty.
- /secrets/:id/usages now responds with { agents, skills }.
- The delete dialog displays both kinds inline so the operator knows which
  references to detach first.
2026-05-03 11:02:16 -04:00
Chris Farhood 27db0d3c67 fix(skills): prevent PAT pollution of bundled skills and orphan secrets on delete
Five connected gaps in the original PAT feature, all in company-skills.ts:

1. upsertImportedSkills only protected bundled rows from overwrite when the
   incoming source claimed to be paperclipai/paperclip. A SKILL.md from any
   other org/repo whose key resolves to paperclipai/paperclip/<slug> would
   hijack the bundled row and gain a sourceAuthSecretId. Broadened: any
   non-bundled incoming is rejected when existing is paperclip_bundled.

2. The metadata-build block preserved sourceAuthSecretId from existing
   indiscriminately, so any pollution of a bundled row was kept across every
   ensureBundledSkills re-upsert. Skip preservation when existing is bundled.

3. importFromSource's auth-token loop wrote sourceAuthSecretId for every
   imported skill including any bundled ones that snuck through. Defense in
   depth: skip skills with sourceKind === "paperclip_bundled".

4. updateSkillAuth had no guard, so the PATCH /skills/:id/auth route could
   attach a PAT to a bundled skill via direct API call. Reject explicitly.

5. deleteSkill removed the secret without checking whether any sibling skill
   still referenced it via metadata.sourceAuthSecretId. Re-imports preserve
   that reference, so two skills could share a secret and deleting one would
   orphan the other's reference. Now skip the remove if another skill in the
   same company still references the secret.
2026-05-03 11:00:02 -04:00
Chris Farhood 5c681340f3 revert: restore paperclip-dev skill (validation requires it for now) 2026-05-03 10:24:00 -04:00
Chris Farhood 85cbbc9263 revert: restore paperclip-dev skill (validation requires it for now)
The earlier fix/remove-paperclip-dev-skill removed the bundled skill,
but companies have stale company_skills rows that reference it as
required, breaking 'Invalid company skill selection' validation. Put
the file back to unblock; the underlying force-required-on-bundled bug
remains and should be fixed in code rather than by deleting the skill.
2026-05-03 10:23:54 -04:00
Chris Farhood acbfcb7d00 Merge branch 'feat/secrets-management-ui' into local 2026-05-03 09:45:59 -04:00
Chris Farhood 5f3cd831a1 Merge branch 'feat/secrets-management-ui' into dev 2026-05-03 08:00:26 -04:00
Chris Farhood 6cb333b986 fix(ui): order Secrets sidebar item right after Environments 2026-05-03 08:00:14 -04:00
Chris Farhood 18f550b946 fix(ci): make Docker Hub login non-blocking on dev build
The self-hosted runner has been hitting context-deadline timeouts to
docker.io. The actual image push goes to GHCR, so the Docker Hub login
is only there to avoid pull rate limits. Mark it continue-on-error so
transient docker.io connectivity issues don't fail the whole build —
base image pulls fall back to anonymous and proceed.
2026-05-02 17:30:04 -04:00
Chris Farhood 191491a57f feat(secrets): company secrets management UI
New /company/settings/secrets page with create, rotate, edit, and delete
flows. Adds a Secrets entry to the company settings sidebar (and tab nav).
Each row shows name, description, version, and time since last rotation;
per-row actions open dialogs for rotate (textarea for new value), edit
(name + description), and delete (confirmation).

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:20:14 -04:00
Chris Farhood e8579d5c66 feat(import-export): complete company portability — secrets export/import and env round-tripping
Adds opt-in secret export/import: secret values are resolved (and optionally
decrypted) into the portability manifest, and re-created with conflict
handling on import. Fixes env round-tripping so both secret_ref and plain
bindings survive export/import cycles.
2026-05-01 08:18:54 -04:00
Chris Farhood 9e30b72b27 test(skills): cover PAT import, skill auth, and scan-projects routes
- importFromSource is invoked with the PAT when one is supplied in the body
- PATCH /skills/:id/auth updates and clears tokens, with matching activity log entries
- POST /skills/scan-projects is reachable to agents that hold canCreateAgents
- Update existing import-permission assertion to include the new authToken arg
2026-05-01 07:58:51 -04:00
Chris Farhood 7b12d907cc feat(skills): scan re-scans existing GitHub/sks_sh sources for new skills
When the project workspace scan runs, also iterate the source locators of
all accepted GitHub and sks_sh skills, re-fetch each source, and upsert any
skills that have appeared since the last import. Per-source failures are
collected as warnings instead of aborting the whole scan.
2026-05-01 07:43:02 -04:00
Chris Farhood d1d592d793 fix(security): use manual redirects when PAT is attached
Token-free requests follow redirects normally to support renamed/transferred
GitHub repos. Manual redirect policy is only needed when a PAT is attached,
to prevent the bearer token from being forwarded to attacker-controlled
redirect targets.
2026-05-01 07:41:57 -04:00
Chris Farhood 3dfb859676 feat(skills): GitHub PAT support for private skill repos
- Add optional authToken to skill import for GitHub private repos
- Store PAT as encrypted company secret (skill-pat:{skillId})
- Thread auth token through ghFetch and GitHub resolution helpers
- Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill
- Preserve sourceAuthSecretId across skill re-imports/updates
- Delete PAT secret on PAT clear and on skill deletion to prevent orphans
- UI: Add PAT input field in import form for GitHub URLs
- UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT
2026-05-01 07:41:48 -04:00
127 changed files with 32776 additions and 645 deletions
+77
View File
@@ -0,0 +1,77 @@
name: "Build: Dev"
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image-tag: ${{ steps.tag.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image tag
id: tag
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: admin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.farh.net/farhoodlabs/paperclip-dev
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: .farhoodlabs/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
update-infra:
needs: build
runs-on: ubuntu-latest
steps:
- name: Update dev image tag in infra repo
run: |
SHA="${{ needs.build.outputs.image-tag }}"
FILE="overlays/dev/kustomization.yaml"
response=$(curl -sS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE")
file_sha=$(echo "$response" | jq -r '.sha')
content=$(echo "$response" | jq -r '.content' | base64 -d)
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
encoded=$(printf '%s' "$new_content" | base64 -w 0)
curl -sS -X PUT \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
+48
View File
@@ -0,0 +1,48 @@
name: "Build: Production"
on:
push:
branches: [local]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: admin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.farh.net/farhoodlabs/paperclip
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: .farhoodlabs/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
+79
View File
@@ -0,0 +1,79 @@
# Paperclip Fork — Project Context
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
Fork repo: https://git.farh.net/farhoodlabs/paperclip
## Branch Model
| Branch | Purpose |
|---|---|
| `master` | Pure mirror of `upstream/master`. No fork-specific files in the tree. Sync via `git push origin upstream/master:master --force-with-lease`. |
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` (defined on `dev`) on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `git.farh.net/farhoodlabs/paperclip`. |
| `dev` | Development branch and canonical home of the `.farhoodlabs/` overlay and the `assemble-local.yml` action. Based on upstream/master. Builds `git.farh.net/farhoodlabs/paperclip-dev` on every push. |
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
## Fork Overlay (`.farhoodlabs/`)
Files committed to `.farhoodlabs/` on `dev` (the canonical source) that get copied into position on `local` by the assemble action:
```
.farhoodlabs/
CLAUDE.md → CLAUDE.md (repo root)
Dockerfile → Dockerfile
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
```
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
To modify fork-specific files, edit them in `.farhoodlabs/` on `dev` and push. The assemble action will apply them to `local` on the next `master` push (or trigger it manually via `workflow_dispatch`).
## Pending Upstream PRs (included in `local`)
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
| PR | Branch | Method | Notes |
|---|---|---|---|
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
## Common Tasks
### Sync upstream into master
```bash
git fetch upstream
git push origin upstream/master:master --force-with-lease
# assemble-local.yml triggers automatically and rebuilds local
```
### Add a new pending PR to local
Edit `.github/workflows/assemble-local.yml` on `dev`:
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
### Remove a PR after upstream merges it
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `dev`.
### Submit a new PR to upstream
Branch from `upstream/master` (not from `local` or `master`):
```bash
git fetch upstream
git checkout -b feat/my-feature upstream/master
```
### Modify the fork Dockerfile
Edit `.farhoodlabs/Dockerfile` on `dev`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
## Deployment
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `git.farh.net/farhoodlabs/paperclip:latest`.
## Key Files
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `dev` (canonical) and are also kept in sync on `local`
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
+107
View File
@@ -0,0 +1,107 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
# Modify the existing node user/group to have the specified UID/GID to match host user
RUN usermod -u $USER_UID --non-unique node \
&& groupmod -g $USER_GID --non-unique node \
&& usermod -g $USER_GID -d /paperclip node
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY cli/package.json cli/
COPY server/package.json server/
COPY ui/package.json ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/skills-catalog/package.json packages/skills-catalog/
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
COPY patches/ patches/
COPY scripts/link-plugin-dev-sdk.mjs scripts/
RUN pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY --from=deps /app /app
COPY . .
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
WORKDIR /app
COPY --chown=node:node --from=build /app /app
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools, mmx-cli
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
&& chmod +x /usr/local/bin/kubectl \
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& mv /root/.local/bin/uv /usr/local/bin/uv \
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
&& curl -fsSL https://codeberg.org/forgejo-contrib/forgejo-cli/releases/download/v0.4.1/forgejo-cli-linux.tar.gz | tar -xzf - -C /usr/local/bin \
&& chmod +x /usr/local/bin/fj \
&& curl -fsSL https://github.com/JKamsker/forgejo-cli-ex/releases/download/v0.1.7/fj-ex-linux-x86_64.tar.gz | tar -xzf - -C /usr/local/bin \
&& chmod +x /usr/local/bin/fj-ex \
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
&& chmod +x /usr/local/bin/fgj \
&& curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \
&& chmod +x /usr/local/bin/tea \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
&& npm install --global --omit=dev mmx-cli \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \
PORT=3100 \
SERVE_UI=true \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_INSTANCE_ID=default \
USER_UID=${USER_UID} \
USER_GID=${USER_GID} \
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
OPENCODE_ALLOW_ALL_MODELS=true
VOLUME ["/paperclip"]
EXPOSE 3100
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
+77
View File
@@ -0,0 +1,77 @@
name: "Build: Dev"
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image-tag: ${{ steps.tag.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image tag
id: tag
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: admin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.farh.net/farhoodlabs/paperclip-dev
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: .farhoodlabs/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
update-infra:
needs: build
runs-on: ubuntu-latest
steps:
- name: Update dev image tag in infra repo
run: |
SHA="${{ needs.build.outputs.image-tag }}"
FILE="overlays/dev/kustomization.yaml"
response=$(curl -sS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE")
file_sha=$(echo "$response" | jq -r '.sha')
content=$(echo "$response" | jq -r '.content' | base64 -d)
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
encoded=$(printf '%s' "$new_content" | base64 -w 0)
curl -sS -X PUT \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
+48
View File
@@ -0,0 +1,48 @@
name: "Build: Production"
on:
push:
branches: [local]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.farh.net
username: admin
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.farh.net/farhoodlabs/paperclip
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: .farhoodlabs/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
+14 -50
View File
@@ -1,55 +1,19 @@
# Disabled in fork — Docker builds are handled by the fork overlay:
# build-prod.yml triggers on `local` branch → ghcr.io/farhoodlabs/paperclip
# build-dev.yml triggers on `dev` branch → ghcr.io/farhoodlabs/paperclip-dev
# See .farhoodlabs/.github/workflows/ and .github/workflows/assemble-local.yml
#
# NOTE: upstream may overwrite this file when master is synced. Re-apply if that happens,
# or use the sync-upstream.yml action which re-applies these overrides automatically.
name: Docker name: Docker
on: on:
push: workflow_dispatch:
branches: inputs:
- "master" note:
tags: description: "Disabled in fork. Use build-prod.yml (local) or build-dev.yml (dev)."
- "v*" required: false
permissions:
contents: read
packages: write
jobs: jobs:
build-and-push: disabled:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps: steps:
- name: Checkout - run: echo "Disabled. See build-prod.yml and build-dev.yml."
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 }}
+10 -90
View File
@@ -1,96 +1,16 @@
# Disabled in fork — `gh` CLI and GitHub-specific commands are not available on Gitea.
# Lockfile refreshes are managed directly in development workflows.
#
# NOTE: upstream may overwrite this file when master is synced. Re-apply if that happens.
name: Refresh Lockfile name: Refresh Lockfile
on: on:
push:
branches:
- master
workflow_dispatch: workflow_dispatch:
inputs:
concurrency: note:
group: refresh-lockfile-master description: "Disabled in fork. Uses GitHub-specific gh CLI."
cancel-in-progress: false required: false
jobs: jobs:
refresh: disabled:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps: steps:
- name: Checkout repository - run: echo "Disabled. Lockfile management requires GitHub-specific tooling."
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Refresh pnpm lockfile
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
- name: Fail on unexpected file changes
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
- name: Create or update pull request
id: upsert-pr
env:
GH_TOKEN: ${{ github.token }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
echo "pr_url=" >> "$GITHUB_OUTPUT"
exit 0
fi
BRANCH="chore/refresh-lockfile"
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push --force origin "$BRANCH"
# Only reuse an open PR from this repository owner, not a fork with the same branch name.
pr_url="$(
gh pr list --state open --head "$BRANCH" --json url,headRepositoryOwner \
--jq ".[] | select(.headRepositoryOwner.login == \"$REPO_OWNER\") | .url" |
head -n 1
)"
if [ -z "$pr_url" ]; then
pr_url="$(gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml.")"
echo "Created new PR: $pr_url"
else
echo "PR already exists: $pr_url"
fi
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge for lockfile PR
if: steps.upsert-pr.outputs.pr_url != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}"
+8 -265
View File
@@ -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 name: Release
on: on:
push:
branches:
- master
workflow_dispatch: workflow_dispatch:
inputs: inputs:
source_ref: note:
description: Commit SHA, branch, or tag to publish as stable description: "Disabled in fork."
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.
required: false 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: jobs:
verify_canary: disabled:
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps: steps:
- name: Checkout repository - run: echo "Disabled in fork."
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"
+70
View File
@@ -0,0 +1,70 @@
name: Sync upstream
# Syncs upstream/master into this fork's master, then re-applies fork overrides
# for any upstream workflow files that should not run in the fork (docker.yml, release.yml).
# Triggers assemble-local.yml automatically via the master push.
#
# Run manually or on a schedule to keep master current with upstream.
on:
schedule:
- cron: '0 7 * * *' # daily at 2am EST (UTC-5)
workflow_dispatch:
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout master
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Fetch upstream
run: |
git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true
git fetch upstream
- name: Fast-forward master to upstream
run: |
git merge --ff-only upstream/master || {
echo "::error::Cannot fast-forward master to upstream/master — diverged history"
echo "::error::Resolve manually: git fetch upstream && git rebase upstream/master"
exit 1
}
- name: Re-apply fork workflow overrides
run: |
# These files are overridden in the fork to prevent upstream workflows from
# running on fork pushes. Re-apply after each upstream sync.
OVERRIDE_FILES=(
".github/workflows/docker.yml"
".github/workflows/release.yml"
)
changed=false
for f in "${OVERRIDE_FILES[@]}"; do
fork_version=$(git show origin/master:"$f" 2>/dev/null || true)
current=$(cat "$f" 2>/dev/null || true)
if [ "$fork_version" != "$current" ]; then
echo "Re-applying fork override: $f"
git checkout origin/master -- "$f"
changed=true
fi
done
if [ "$changed" = true ]; then
git add "${OVERRIDE_FILES[@]}"
git commit -m "chore(ci): re-apply fork workflow overrides after upstream sync"
fi
- name: Push master
run: git push origin master
+79
View File
@@ -0,0 +1,79 @@
# Paperclip Fork — Project Context
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
Fork repo: https://git.farh.net/farhoodlabs/paperclip
## Branch Model
| Branch | Purpose |
|---|---|
| `master` | Pure mirror of `upstream/master`. No fork-specific files in the tree. Sync via `git push origin upstream/master:master --force-with-lease`. |
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` (defined on `dev`) on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `git.farh.net/farhoodlabs/paperclip`. |
| `dev` | Development branch and canonical home of the `.farhoodlabs/` overlay and the `assemble-local.yml` action. Based on upstream/master. Builds `git.farh.net/farhoodlabs/paperclip-dev` on every push. |
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete` — open PRs to upstream, never rebase onto master/local. |
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
## Fork Overlay (`.farhoodlabs/`)
Files committed to `.farhoodlabs/` on `dev` (the canonical source) that get copied into position on `local` by the assemble action:
```
.farhoodlabs/
CLAUDE.md → CLAUDE.md (repo root)
Dockerfile → Dockerfile
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
```
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
To modify fork-specific files, edit them in `.farhoodlabs/` on `dev` and push. The assemble action will apply them to `local` on the next `master` push (or trigger it manually via `workflow_dispatch`).
## Pending Upstream PRs (included in `local`)
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
| PR | Branch | Method | Notes |
|---|---|---|---|
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
## Common Tasks
### Sync upstream into master
```bash
git fetch upstream
git push origin upstream/master:master --force-with-lease
# assemble-local.yml triggers automatically and rebuilds local
```
### Add a new pending PR to local
Edit `.github/workflows/assemble-local.yml` on `dev`:
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
### Remove a PR after upstream merges it
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `dev`.
### Submit a new PR to upstream
Branch from `upstream/master` (not from `local` or `master`):
```bash
git fetch upstream
git checkout -b feat/my-feature upstream/master
```
### Modify the fork Dockerfile
Edit `.farhoodlabs/Dockerfile` on `dev`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
## Deployment
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `git.farh.net/farhoodlabs/paperclip:latest`.
## Key Files
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `dev` (canonical) and are also kept in sync on `local`
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
+1
View File
@@ -22,6 +22,7 @@ COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/ COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/skills-catalog/package.json packages/skills-catalog/
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/ COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
+177
View File
@@ -133,6 +133,8 @@ export interface PaperclipSkillEntry {
key: string; key: string;
runtimeName: string; runtimeName: string;
source: string; source: string;
sourceStatus?: "available" | "missing";
missingDetail?: string | null;
required?: boolean; required?: boolean;
requiredReason?: string | null; requiredReason?: string | null;
} }
@@ -161,6 +163,22 @@ interface PersistentSkillSnapshotOptions {
warnings?: string[]; warnings?: string[];
} }
interface RuntimeMountedSkillSnapshotOptions {
adapterType: string;
availableEntries: PaperclipSkillEntry[];
desiredSkills: string[];
configuredDetail: string | ((entry: PaperclipSkillEntry) => string | null);
missingDetail?: string;
mode?: "ephemeral" | "unsupported";
supported?: boolean;
unsupportedDetail?: string | ((entry: PaperclipSkillEntry) => string | null);
warnings?: string[];
externalInstalled?: Map<string, InstalledSkillTarget>;
externalLocationLabel?: string | null;
externalDetail?: string;
skillsHome?: string;
}
function normalizePathSlashes(value: string): string { function normalizePathSlashes(value: string): string {
return value.replaceAll("\\", "/"); return value.replaceAll("\\", "/");
} }
@@ -193,6 +211,26 @@ function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
}; };
} }
function isPaperclipSkillSourceMissing(entry: PaperclipSkillEntry) {
return entry.sourceStatus === "missing";
}
function resolvePaperclipSkillMissingDetail(
entry: PaperclipSkillEntry,
fallback: string,
) {
return entry.missingDetail?.trim() || fallback;
}
function resolveSkillDetail(
detail: string | ((entry: PaperclipSkillEntry) => string | null) | null | undefined,
entry: PaperclipSkillEntry,
): string | null {
if (typeof detail === "function") return detail(entry);
if (typeof detail === "string") return detail;
return null;
}
function resolveInstalledEntryTarget( function resolveInstalledEntryTarget(
skillsHome: string, skillsHome: string,
entryName: string, entryName: string,
@@ -1381,6 +1419,120 @@ export async function readInstalledSkillTargets(skillsHome: string): Promise<Map
return out; return out;
} }
export function buildRuntimeMountedSkillSnapshot(
options: RuntimeMountedSkillSnapshotOptions,
): AdapterSkillSnapshot {
const {
adapterType,
availableEntries,
desiredSkills,
configuredDetail,
missingDetail = "Paperclip cannot find this skill in the local runtime skills directory.",
mode = "ephemeral",
externalInstalled,
externalLocationLabel,
externalDetail = "Installed outside Paperclip management.",
skillsHome,
} = options;
const supported = options.supported ?? mode !== "unsupported";
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = [];
const warnings = [...(options.warnings ?? [])];
for (const available of availableEntries) {
const desired = desiredSet.has(available.key);
if (isPaperclipSkillSourceMissing(available)) {
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: "missing",
sourcePath: null,
targetPath: null,
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
continue;
}
const configured = supported && mode === "ephemeral" && desired;
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: configured ? "configured" : "available",
sourcePath: available.source,
targetPath: null,
detail: desired
? configured
? resolveSkillDetail(configuredDetail, available)
: resolveSkillDetail(
options.unsupportedDetail
?? "Desired state is stored in Paperclip only; this adapter cannot apply skills at runtime.",
available,
)
: null,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
}
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: null,
detail: missingDetail,
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
});
}
if (externalInstalled) {
for (const [name, installedEntry] of externalInstalled.entries()) {
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
origin: "user_installed",
originLabel: "User-installed",
locationLabel: skillLocationLabel(externalLocationLabel),
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? (skillsHome ? path.join(skillsHome, name) : null),
detail: externalDetail,
});
}
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType,
supported,
mode,
desiredSkills,
entries,
warnings,
};
}
export function buildPersistentSkillSnapshot( export function buildPersistentSkillSnapshot(
options: PersistentSkillSnapshotOptions, options: PersistentSkillSnapshotOptions,
): AdapterSkillSnapshot { ): AdapterSkillSnapshot {
@@ -1404,6 +1556,26 @@ export function buildPersistentSkillSnapshot(
for (const available of availableEntries) { for (const available of availableEntries) {
const installedEntry = installed.get(available.runtimeName) ?? null; const installedEntry = installed.get(available.runtimeName) ?? null;
const desired = desiredSet.has(available.key); const desired = desiredSet.has(available.key);
if (isPaperclipSkillSourceMissing(available)) {
entries.push({
key: available.key,
runtimeName: available.runtimeName,
desired,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, available.runtimeName),
detail: resolvePaperclipSkillMissingDetail(
available,
missingDetail,
),
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
...buildManagedSkillOrigin(available),
});
continue;
}
let state: AdapterSkillEntry["state"] = "available"; let state: AdapterSkillEntry["state"] = "available";
let managed = false; let managed = false;
let detail: string | null = null; let detail: string | null = null;
@@ -1496,6 +1668,11 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
key, key,
runtimeName, runtimeName,
source, source,
sourceStatus: entry.sourceStatus === "missing" ? "missing" : "available",
missingDetail:
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
? entry.missingDetail.trim()
: null,
required: asBoolean(entry.required, false), required: asBoolean(entry.required, false),
requiredReason: requiredReason:
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
@@ -0,0 +1,189 @@
CREATE TABLE IF NOT EXISTS "document_annotation_threads" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"document_key" text NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"anchor_state" text DEFAULT 'active' NOT NULL,
"original_revision_id" uuid,
"original_revision_number" integer NOT NULL,
"current_revision_id" uuid,
"current_revision_number" integer NOT NULL,
"selected_text" text NOT NULL,
"prefix_text" text DEFAULT '' NOT NULL,
"suffix_text" text DEFAULT '' NOT NULL,
"normalized_start" integer NOT NULL,
"normalized_end" integer NOT NULL,
"markdown_start" integer NOT NULL,
"markdown_end" integer NOT NULL,
"anchor_confidence" text DEFAULT 'exact' NOT NULL,
"anchor_selector" jsonb NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"resolved_by_agent_id" uuid,
"resolved_by_user_id" text,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_annotation_comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"thread_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"body" text NOT NULL,
"author_type" text NOT NULL,
"author_agent_id" uuid,
"author_user_id" text,
"created_by_run_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_annotation_anchor_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"thread_id" uuid NOT NULL,
"document_id" uuid NOT NULL,
"from_revision_id" uuid,
"from_revision_number" integer,
"to_revision_id" uuid,
"to_revision_number" integer NOT NULL,
"previous_anchor" jsonb NOT NULL,
"next_anchor" jsonb,
"anchor_state" text NOT NULL,
"anchor_confidence" text NOT NULL,
"failure_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_company_id_companies_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_issue_id_issues_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_document_id_documents_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_original_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_original_revision_id_document_revisions_id_fk" FOREIGN KEY ("original_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_current_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_current_revision_id_document_revisions_id_fk" FOREIGN KEY ("current_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_created_by_agent_id_agents_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_threads_resolved_by_agent_id_agents_id_fk') THEN
ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_company_id_companies_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_thread_id_document_annotation_threads_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_issue_id_issues_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_document_id_documents_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_author_agent_id_agents_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk') THEN
ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_company_id_companies_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_annotation_threads"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_document_id_documents_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk" FOREIGN KEY ("from_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk') THEN
ALTER TABLE "document_annotation_anchor_snapshots" ADD CONSTRAINT "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk" FOREIGN KEY ("to_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_document_status_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_issue_status_idx" ON "document_annotation_threads" USING btree ("company_id","issue_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_current_revision_open_idx" ON "document_annotation_threads" USING btree ("company_id","document_id","current_revision_id","status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_anchor_state_idx" ON "document_annotation_threads" USING btree ("company_id","anchor_state");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_thread_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","thread_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_issue_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","issue_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_document_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","document_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_comments_body_search_idx" ON "document_annotation_comments" USING gin ("body" gin_trgm_ops);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_thread_created_at_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","thread_id","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_annotation_anchor_snapshots_company_document_revision_idx" ON "document_annotation_anchor_snapshots" USING btree ("company_id","document_id","to_revision_number");
File diff suppressed because it is too large Load Diff
@@ -638,6 +638,13 @@
"when": 1779573019125, "when": 1779573019125,
"tag": "0090_resource_memberships", "tag": "0090_resource_memberships",
"breakpoints": true "breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1778810394522,
"tag": "0091_old_swarm",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,42 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationAnchorState,
} from "@paperclipai/shared";
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { documentAnnotationThreads } from "./document_annotation_threads.js";
import { documentRevisions } from "./document_revisions.js";
import { documents } from "./documents.js";
export const documentAnnotationAnchorSnapshots = pgTable(
"document_annotation_anchor_snapshots",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
fromRevisionId: uuid("from_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
fromRevisionNumber: integer("from_revision_number"),
toRevisionId: uuid("to_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
toRevisionNumber: integer("to_revision_number").notNull(),
previousAnchor: jsonb("previous_anchor").$type<DocumentAnnotationAnchorSnapshot>().notNull(),
nextAnchor: jsonb("next_anchor").$type<DocumentAnnotationAnchorSnapshot | null>(),
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull(),
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().notNull(),
failureReason: text("failure_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyThreadCreatedAtIdx: index("document_annotation_anchor_snapshots_company_thread_created_at_idx").on(
table.companyId,
table.threadId,
table.createdAt,
),
companyDocumentRevisionIdx: index("document_annotation_anchor_snapshots_company_document_revision_idx").on(
table.companyId,
table.documentId,
table.toRevisionNumber,
),
}),
);
@@ -0,0 +1,44 @@
import type { IssueCommentAuthorType } from "@paperclipai/shared";
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentAnnotationThreads } from "./document_annotation_threads.js";
import { documents } from "./documents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
export const documentAnnotationComments = pgTable(
"document_annotation_comments",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
body: text("body").notNull(),
authorType: text("author_type").$type<IssueCommentAuthorType>().notNull(),
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
authorUserId: text("author_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyThreadCreatedAtIdx: index("document_annotation_comments_company_thread_created_at_idx").on(
table.companyId,
table.threadId,
table.createdAt,
),
companyIssueCreatedAtIdx: index("document_annotation_comments_company_issue_created_at_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on(
table.companyId,
table.documentId,
table.createdAt,
),
bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")),
}),
);
@@ -0,0 +1,70 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorState,
DocumentAnnotationThreadStatus,
} from "@paperclipai/shared";
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentRevisions } from "./document_revisions.js";
import { documents } from "./documents.js";
import { issues } from "./issues.js";
export const documentAnnotationThreads = pgTable(
"document_annotation_threads",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
documentKey: text("document_key").notNull(),
status: text("status").$type<DocumentAnnotationThreadStatus>().notNull().default("open"),
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull().default("active"),
originalRevisionId: uuid("original_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
originalRevisionNumber: integer("original_revision_number").notNull(),
currentRevisionId: uuid("current_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }),
currentRevisionNumber: integer("current_revision_number").notNull(),
selectedText: text("selected_text").notNull(),
prefixText: text("prefix_text").notNull().default(""),
suffixText: text("suffix_text").notNull().default(""),
normalizedStart: integer("normalized_start").notNull(),
normalizedEnd: integer("normalized_end").notNull(),
markdownStart: integer("markdown_start").notNull(),
markdownEnd: integer("markdown_end").notNull(),
anchorConfidence: text("anchor_confidence")
.$type<DocumentAnnotationAnchorConfidence>()
.notNull()
.default("exact"),
anchorSelector: jsonb("anchor_selector").$type<DocumentAnnotationAnchorSelector>().notNull(),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
resolvedByUserId: text("resolved_by_user_id"),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyDocumentStatusIdx: index("document_annotation_threads_company_document_status_idx").on(
table.companyId,
table.documentId,
table.status,
),
companyIssueStatusIdx: index("document_annotation_threads_company_issue_status_idx").on(
table.companyId,
table.issueId,
table.status,
),
companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on(
table.companyId,
table.documentId,
table.currentRevisionId,
table.status,
),
companyAnchorStateIdx: index("document_annotation_threads_company_anchor_state_idx").on(
table.companyId,
table.anchorState,
),
}),
);
+3
View File
@@ -55,6 +55,9 @@ export { issueAttachments } from "./issue_attachments.js";
export { documents } from "./documents.js"; export { documents } from "./documents.js";
export { documentRevisions } from "./document_revisions.js"; export { documentRevisions } from "./document_revisions.js";
export { issueDocuments } from "./issue_documents.js"; export { issueDocuments } from "./issue_documents.js";
export { documentAnnotationThreads } from "./document_annotation_threads.js";
export { documentAnnotationComments } from "./document_annotation_comments.js";
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
export { heartbeatRuns } from "./heartbeat_runs.js"; export { heartbeatRuns } from "./heartbeat_runs.js";
export { heartbeatRunEvents } from "./heartbeat_run_events.js"; export { heartbeatRunEvents } from "./heartbeat_run_events.js";
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js"; export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
@@ -0,0 +1,172 @@
# @paperclipai/plugin-kubernetes (alpha)
First-party Paperclip sandbox-provider plugin for Kubernetes.
**Alpha:** the default backend (`sandbox-cr`) is built on `kubernetes-sigs/agent-sandbox` v1alpha1 — expect breaking changes as that CRD evolves toward Beta. A stable fallback backend (`job`, using `batch/v1` Job) is available for clusters without agent-sandbox installed, but it does NOT support multi-command exec (paperclip-server's adapter-install pattern requires sandbox-cr).
## Prerequisites
### For `sandbox-cr` backend (default, recommended)
1. A Kubernetes cluster running k8s 1.27+
2. [`kubernetes-sigs/agent-sandbox`](https://github.com/kubernetes-sigs/agent-sandbox) controller installed in the cluster (alpha — installs the `sandboxes.agents.x-k8s.io/v1alpha1` CRD and controller)
3. Paperclip-server running with access to the cluster (in-cluster via `inCluster: true` or external via `kubeconfig`)
### For `job` backend (stable fallback)
1. A Kubernetes cluster running k8s 1.27+
2. Paperclip-server with cluster access — no additional controllers or CRDs required
## Installation
```bash
paperclipai plugin install @paperclipai/plugin-kubernetes
```
Or, for local development:
```bash
paperclipai plugin install --local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes
```
## Backends
The plugin supports two backend modes, selected via the `backend` config field:
| Backend | Default | Stability | Multi-command exec | Requires |
|---|---|---|---|---|
| `sandbox-cr` | Yes | Alpha | Yes | `kubernetes-sigs/agent-sandbox` controller |
| `job` | No | Stable | No | Nothing beyond k8s 1.27+ |
**`sandbox-cr` (default):** Creates a `Sandbox` CR (`agents.x-k8s.io/v1alpha1`) whose controller provisions a long-lived pod running `sleep infinity`. paperclip-server execs individual commands into the running pod — this is the multi-command adapter-install pattern. When you `releaseLease`, the Sandbox CR is deleted and the controller tears down the pod.
**`job` (stable fallback):** Creates a `batch/v1` Job. The container entrypoint runs once and exits — no multi-command exec possible. Use this when you cannot install agent-sandbox, or when you need strictly stable Kubernetes APIs. Note: paperclip-server's adapter-install pattern will not work in job mode.
### Migrating from `job` to `sandbox-cr`
1. Install the agent-sandbox controller: `kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/install.yaml`
2. Update your environment config to set `backend: "sandbox-cr"` (or remove `backend` since `sandbox-cr` is the default)
3. New leases will use the Sandbox CR backend. Existing leases created with `job` mode continue to use job semantics until they are released.
## Configuration
Create a `sandbox` environment with `driver: kubernetes`. One of these auth fields is required:
- `inCluster: true` — use the in-pod ServiceAccount credentials (when paperclip-server runs inside the same cluster).
- `kubeconfig: <YAML>` — inline kubeconfig (stored as a company secret).
- `kubeconfigSecretRef: <secret-uuid>` — reference to an existing Paperclip secret.
Common optional fields:
| Field | Default | Purpose |
|---|---|---|
| `backend` | `"sandbox-cr"` | `sandbox-cr` (alpha, requires agent-sandbox controller) or `job` (stable, one-shot entrypoint). |
| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, acpx_local, pi_local). Determines runtime image + env keys + egress allow-list. |
| `namespacePrefix` | `"paperclip-"` | Prefix for the per-company tenant namespace. |
| `paperclipServerNamespace` | `"paperclip"` | Namespace where paperclip-server pods run. Generated egress policies use this so agent pods can call back to the server. |
| `companySlug` | derived from companyId | Override the auto-derived company slug. |
| `imageRegistry` | (none) | Override the default registry for agent runtime images. |
| `imageAllowList` | `[]` | Glob patterns of allowed `target.imageOverride` values. Empty = no override permitted. |
| `imagePullSecrets` | `[]` | Names of pre-created Docker image pull secrets in the tenant namespace. |
| `egressAllowFqdns` | `[]` | Additional FQDNs (beyond adapter defaults like `api.anthropic.com`). |
| `egressAllowCidrs` | `[]` | Additional CIDRs to allow HTTPS egress to. CIDR egress is restricted to TCP port 443. |
| `egressMode` | `"standard"` | `standard` (NetworkPolicy + CIDRs, plus public HTTPS fallback when adapter FQDNs are configured) or `cilium` (CiliumNetworkPolicy + exact FQDN allow-list). |
| `runtimeClassName` | (none) | e.g. `kata-fc` for Firecracker-backed microVMs. Cluster must have the RuntimeClass installed. |
| `serviceAccountAnnotations` | `{}` | Annotations applied to per-tenant ServiceAccount (e.g. IRSA `eks.amazonaws.com/role-arn`). |
| `jobTtlSecondsAfterFinished` | `900` | Seconds after a Job completes before garbage-collection. |
| `podActivityDeadlineSec` | `3600` | Hard ceiling on a single run's wall-clock time. |
Full JSON Schema in `src/manifest.ts`.
## What gets created in your cluster
For each company that runs agents (created lazily on first dispatch):
```
Namespace paperclip-{companySlug} (PSS: restricted enforce + audit)
ServiceAccount paperclip-tenant-sa
Role paperclip-tenant-role (only get pods/log)
RoleBinding paperclip-tenant-rb
ResourceQuota paperclip-quota (pods, requests/limits cpu+memory)
LimitRange paperclip-limits (container max/min/default/defaultRequest)
NetworkPolicy paperclip-deny-all (deny ingress + egress baseline)
NetworkPolicy paperclip-egress-allow (DNS + paperclip-server callback + user CIDRs + public HTTPS fallback for adapter FQDNs)
OR CiliumNetworkPolicy paperclip-egress-fqdn if egressMode=cilium
```
Standard Kubernetes NetworkPolicy cannot match FQDNs. In `egressMode: "standard"`, adapter-default FQDNs such as `api.anthropic.com` trigger a public IPv4 HTTPS fallback that excludes private and link-local ranges, so default agent runs can reach model APIs without opening intra-cluster/private-network egress. Use `egressMode: "cilium"` when you need exact FQDN enforcement.
For each agent run (sandbox-cr backend):
```
Sandbox CR pc-{ulid} (agents.x-k8s.io/v1alpha1; explicit delete on release)
Pod pc-{ulid}-{podSuffix} (managed by Sandbox controller; torn down on CR delete)
Secret pc-{ulid}-env (owned by Sandbox CR; cascade-deleted)
```
## Fast workspace uploads
The `sandbox-cr` backend recognizes the chunked base64 upload protocol emitted by `@paperclipai/adapter-utils` for workspace, skill, and config-seed file transfers. Instead of running one Kubernetes exec per base64 chunk, the plugin buffers the upload in worker memory and flushes the final payload through a single `head -c <bytes> | base64 -d` exec with stdin.
The interceptor is intentionally narrow: only the exact `mkdir`/`printf`/`base64 -d` command shape generated by adapter-utils is optimized. Unknown commands and missing init state fall back to normal exec behavior. Uploads over the 100 MB buffer cap fail fast instead of falling back, because earlier chunks were already acknowledged without being written to the pod.
For each agent run (job backend):
```
Job pc-{ulid} (backoffLimit: 0, ttlSecondsAfterFinished from config)
Pod pc-{ulid}-{podSuffix} (owned by Job; cascade-deleted)
Secret pc-{ulid}-env (owned by Job; cascade-deleted)
```
## Security baseline
Every agent pod is:
- non-root (`runAsUser: 1000`, `runAsGroup: 1000`, `runAsNonRoot: true`)
- drops ALL Linux capabilities, `allowPrivilegeEscalation: false`
- `readOnlyRootFilesystem: true` with explicit `emptyDir` mounts for `/workspace`, `/home/paperclip`, `/home/paperclip/.cache`, `/tmp`
- `seccompProfile: RuntimeDefault`
- Tini as PID 1 (reaps zombies, forwards signals)
- `fsGroupChangePolicy: OnRootMismatch` (fast PVC startup; openclaw-operator lesson)
- `automountServiceAccountToken: false`
Plus per-namespace `pod-security.kubernetes.io/enforce: restricted` and a deny-all NetworkPolicy baseline with explicit egress allow-list (DNS, paperclip-server, CIDRs, and either Cilium FQDN rules or standard-mode public HTTPS fallback).
The per-run Secret carrying the bootstrap token and adapter API keys has `ownerReferences` pointing at the owning Sandbox CR or Job, so releasing the lease cascades cleanly to the Pod and Secret.
## Optional Kata-FC microVM isolation
For stronger isolation, install [Kata Containers](https://github.com/kata-containers/kata-containers) with the Firecracker hypervisor, then set `runtimeClassName: kata-fc` in the plugin config. Each agent pod will run inside a Firecracker microVM. Requires nested-virt-capable nodes (bare-metal or specific cloud instance types).
## Roadmap
- **Phase A (done):** `sandbox-cr` backend — multi-command exec via agent-sandbox Sandbox CRD.
- **Phase B:** Warm pool support — pre-provisioned Sandbox CRs for sub-second cold starts. The `SandboxOrchestrator` interface reserves optional `pause?`/`resume?` extension slots.
- **Phase C:** Kata-FC + snapshots — `runtimeClassName: kata-fc` with VM snapshot for fast restore.
- **Phase D:** Contribute back to agent-sandbox upstream if their Beta model diverges from our needs. The `SandboxOrchestrator` interface (`src/sandbox-orchestrator.ts`) is the clean swap point — a new implementation can be added without touching `plugin.ts` business logic.
## Lessons learned (from openclaw-operator)
This plugin adopts patterns from `openclaw-rocks/openclaw-operator`:
- Tini PID 1 (issue #471 — zombie helper processes)
- Read-only rootFS with explicit writable mounts (issue #456 — ~/.config not writable)
- Strategic merge on reconcile (issue #446 — preserve third-party annotations)
- Multi-storage-class testing (issue #448`local-path-provisioner` differences)
- Image version compat matrix (issue #462 — runtime deps cannot resolve after upgrade)
## Development
```bash
cd packages/plugins/sandbox-providers/kubernetes
pnpm install --ignore-workspace
pnpm test # unit tests only (fast)
pnpm typecheck
pnpm build
```
To run the kind-cluster integration test (requires `kubectl --context kind-paperclip` and a pre-loaded alpine image; see `test/integration/end-to-end-run.test.ts`):
```bash
RUN_K8S_INTEGRATION_TESTS=1 pnpm test test/integration/end-to-end-run.test.ts
```
@@ -0,0 +1,137 @@
# Manual smoke test — `@paperclipai/plugin-kubernetes`
Manual sanity check that the plugin works end-to-end against a real
paperclip-server instance and a real Kubernetes cluster (kind for local
dev). Future work may automate this in CI.
## Prerequisites
- A running kind cluster:
```bash
kind create cluster --name paperclip
```
- `kubectl --context kind-paperclip get nodes` returns a node in `Ready` state.
## Steps
### 1. Build the plugin
```bash
cd packages/plugins/sandbox-providers/kubernetes
pnpm install --ignore-workspace
pnpm build
```
Expected: `dist/` populated with compiled `.js` and `.d.ts` files. No errors.
### 2. Start paperclip-server in dev mode
In a separate terminal:
```bash
cd /path/to/paperclip
export PAPERCLIP_HOME=/tmp/paperclip-smoke
export PAPERCLIP_INSTANCE_ID=smoke
export PAPERCLIP_DEPLOYMENT_MODE=local_trusted
pnpm --filter @paperclipai/server dev
```
Wait for `Server listening on 127.0.0.1:3100`.
### 3. Install the plugin via the CLI
```bash
pnpm paperclipai plugin install \
--local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes \
--api-base http://127.0.0.1:3100
```
Expected: `✓ Installed paperclip.kubernetes-sandbox-provider v0.1.0 (ready)`.
### 4. Create a company and a kubernetes sandbox environment
```bash
CO_ID=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"name":"SmokeCo"}' \
http://127.0.0.1:3100/api/companies | jq -r '.id')
KUBECONFIG_CONTENT=$(cat ~/.kube/config | jq -Rs .)
curl -s -X POST -H "Content-Type: application/json" \
-d "{
\"name\": \"k8s-sandbox\",
\"driver\": \"sandbox\",
\"config\": {
\"provider\": \"kubernetes\",
\"kubeconfig\": $KUBECONFIG_CONTENT,
\"companySlug\": \"smoke\",
\"adapterType\": \"claude_local\",
\"imageAllowList\": [\"ghcr.io/paperclipai/agent-runtime-claude:v1\"]
}
}" \
http://127.0.0.1:3100/api/companies/$CO_ID/environments | jq
```
Expected: HTTP 201 with the new environment row.
### 5. Probe the environment
```bash
ENV_ID=$(curl -s http://127.0.0.1:3100/api/companies/$CO_ID/environments | jq -r '.[0].id')
curl -s -X POST -d '{}' -H "Content-Type: application/json" \
http://127.0.0.1:3100/api/environments/$ENV_ID/probe | jq
```
Expected: `{"ok": true, ...}` with a summary mentioning the tenant namespace
(`paperclip-smoke`). On first probe the namespace may not yet exist —
the plugin treats a 404 on `listNamespacedPod` as a successful reachability
check.
### 6. Trigger an agent run
Use the UI or the API to dispatch a run against the `k8s-sandbox` environment.
The plugin's `onEnvironmentAcquireLease` will:
1. `ensureTenant` — provision the `paperclip-smoke` namespace, SA, Role,
RoleBinding, ResourceQuota, LimitRange, NetworkPolicies
2. `buildSandboxCrManifest` — render the security-hardened Sandbox CR manifest
3. `createNamespacedCustomObject` — submit to `agents.x-k8s.io/v1alpha1`
4. `createPerRunSecret` — owned by the Sandbox CR for cascade-delete
5. Fast-upload workspace/config/skill payloads by collapsing adapter-utils chunked uploads into a single stdin-backed exec per file
### 7. Verify the tenant resources
```bash
kubectl --context kind-paperclip get namespace paperclip-smoke
kubectl --context kind-paperclip get all,networkpolicy,resourcequota,limitrange,sa,role,rolebinding -n paperclip-smoke
```
Expected:
- Namespace `paperclip-smoke` exists with PSS labels
(`pod-security.kubernetes.io/enforce=restricted`)
- ServiceAccount `paperclip-tenant-sa`
- Role `paperclip-tenant-role`, RoleBinding `paperclip-tenant-rb`
- ResourceQuota `paperclip-quota`, LimitRange `paperclip-limits`
- NetworkPolicies `paperclip-deny-all` + `paperclip-egress-allow`
- Sandbox `pc-{ulid}` and its managed Pod
- Secret `pc-{ulid}-env` with `ownerReferences` pointing at the Sandbox CR
- Run logs or plugin metadata include `fastUpload: "flush"` entries during workspace/config/skill upload
### 8. Tear down
```bash
kubectl --context kind-paperclip delete namespace paperclip-smoke
kill %1 # paperclip-server
```
### 9. Document the result
In the PR description (or appended to this file as a dated section),
record:
- Date + git SHA
- `kubectl version` server version
- Output of `kubectl get all -n paperclip-smoke` after step 6
- Probe response from step 5
- Time-to-acquire-lease (target: <30s on kind for a cold tenant)
@@ -0,0 +1,22 @@
# This plugin uses only stable Kubernetes APIs. No CRD installation is required.
#
# Minimum cluster version: Kubernetes 1.27+
# - batch/v1 Job (GA since k8s 1.21)
# - core/v1 Pod, Secret, Namespace, ServiceAccount, ResourceQuota, LimitRange (GA since k8s 1.0)
# - rbac.authorization.k8s.io/v1 Role, RoleBinding (GA since k8s 1.8)
# - networking.k8s.io/v1 NetworkPolicy (GA since k8s 1.7)
# - Pod Security Standards namespace labels (GA in k8s 1.25)
# - fsGroupChangePolicy: OnRootMismatch (GA in k8s 1.23)
# - seccompProfile.type: RuntimeDefault (GA in k8s 1.19)
#
# Optional CNI prerequisites for FQDN-based egress (egressMode: cilium):
# - Cilium >= 1.11 with hubble + DNS proxy enabled
# - cilium.io/v2 CiliumNetworkPolicy (provided by Cilium installation)
#
# Optional runtime class for microVM isolation (runtimeClassName: kata-fc):
# - kata-containers with Firecracker hypervisor
# - nested-virt-capable nodes
#
# Future backends (not currently required):
# - kubernetes-sigs/agent-sandbox (when it reaches v1beta1) as an alternative
# backend for warm pools / templates / pause-resume.
@@ -0,0 +1,60 @@
{
"name": "@paperclipai/plugin-kubernetes",
"version": "0.1.0",
"description": "Kubernetes sandbox provider plugin for Paperclip environments",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/plugins/sandbox-providers/kubernetes"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": ["dist", "manifests", "README.md"],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
},
"keywords": [
"paperclip",
"plugin",
"sandbox",
"kubernetes"
],
"scripts": {
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "rm -rf dist && tsc",
"clean": "rm -rf dist",
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
"test": "vitest run --config vitest.config.ts",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,61 @@
export interface AdapterDefaults {
runtimeImage: string;
envKeys: string[];
allowFqdns: string[];
probeCommand: string[];
}
const REGISTRY: Record<string, AdapterDefaults> = {
claude_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-claude:v1",
envKeys: ["ANTHROPIC_API_KEY"],
allowFqdns: ["api.anthropic.com"],
probeCommand: ["claude", "--version"],
},
codex_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-codex:v1",
envKeys: ["OPENAI_API_KEY"],
allowFqdns: ["api.openai.com"],
probeCommand: ["codex", "--version"],
},
gemini_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-gemini:v1",
envKeys: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
allowFqdns: ["generativelanguage.googleapis.com"],
probeCommand: ["gemini", "--version"],
},
cursor_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-cursor:v1",
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
allowFqdns: ["api.anthropic.com", "api.openai.com"],
probeCommand: ["cursor-agent", "--version"],
},
opencode_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-opencode:v1",
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"],
allowFqdns: ["api.anthropic.com", "api.openai.com", "openrouter.ai"],
probeCommand: ["opencode", "--version"],
},
acpx_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-acpx:v1",
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
allowFqdns: ["api.anthropic.com", "api.openai.com"],
probeCommand: ["acpx", "--version"],
},
pi_local: {
runtimeImage: "ghcr.io/paperclipai/agent-runtime-pi:v1",
envKeys: ["ANTHROPIC_API_KEY"],
allowFqdns: ["api.anthropic.com"],
probeCommand: ["pi", "--version"],
},
};
export const KNOWN_ADAPTER_TYPES: ReadonlySet<string> = new Set(Object.keys(REGISTRY));
export function getAdapterDefaults(adapterType: string): AdapterDefaults {
const defaults = REGISTRY[adapterType];
if (!defaults) {
throw new Error(`Unknown adapter type: ${adapterType}`);
}
return defaults;
}
@@ -0,0 +1,69 @@
export interface BuildCiliumNetworkPolicyInput {
namespace: string;
paperclipServerNamespace: string;
egressAllowFqdns: string[];
egressAllowCidrs: string[];
}
// Design note: no ingress rules are defined here. Paperclip-server does NOT
// push to agent pods — agents make outbound (egress) callbacks to
// paperclip-server on port 3100. If server→agent push is ever needed, add a
// targeted ingress rule scoped to the paperclip-server endpoint selector.
export function buildCiliumNetworkPolicyManifest(input: BuildCiliumNetworkPolicyInput): Record<string, unknown> {
const egress: Record<string, unknown>[] = [];
egress.push({
toEndpoints: [
{ matchLabels: { "k8s:io.kubernetes.pod.namespace": "kube-system", "k8s-app": "kube-dns" } },
],
toPorts: [
{
ports: [
{ port: "53", protocol: "UDP" },
{ port: "53", protocol: "TCP" },
],
rules: { dns: [{ matchPattern: "*" }] },
},
],
});
if (input.egressAllowFqdns.length > 0) {
egress.push({
toFQDNs: input.egressAllowFqdns.map((fqdn) => ({ matchName: fqdn })),
toPorts: [{ ports: [{ port: "443", protocol: "TCP" }] }],
});
}
egress.push({
toEndpoints: [
{
matchLabels: {
"k8s:io.kubernetes.pod.namespace": input.paperclipServerNamespace,
app: "paperclip-server",
},
},
],
toPorts: [{ ports: [{ port: "3100", protocol: "TCP" }] }],
});
if (input.egressAllowCidrs.length > 0) {
egress.push({
toCIDRSet: input.egressAllowCidrs.map((cidr) => ({ cidr })),
toPorts: [{ ports: [{ port: "443", protocol: "TCP" }] }],
});
}
return {
apiVersion: "cilium.io/v2",
kind: "CiliumNetworkPolicy",
metadata: {
name: "paperclip-egress-fqdn",
namespace: input.namespace,
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
},
spec: {
endpointSelector: { matchLabels: { "paperclip.io/role": "agent" } },
egress,
},
};
}
@@ -0,0 +1,59 @@
/**
* Glob matching for image references.
* - `*` matches any sequence of characters EXCEPT `/` (so a wildcard doesn't span path segments)
* - `?` matches exactly one character (excluding `/`)
*/
export function globMatch(pattern: string, value: string): boolean {
const re = new RegExp(
"^" +
pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, "[^/]") +
"$",
);
return re.test(value);
}
export interface ResolveImageInput {
imageOverride?: string | null;
}
export interface ResolveImageDefaults {
runtimeImage: string;
}
export interface ResolveImageConfig {
imageAllowList: string[];
imageRegistry?: string;
}
export function resolveImage(
target: ResolveImageInput,
defaults: ResolveImageDefaults,
config: ResolveImageConfig,
): string {
if (target.imageOverride) {
if (!config.imageAllowList.some((p) => globMatch(p, target.imageOverride!))) {
throw new Error(`Image override "${target.imageOverride}" is not in allowlist`);
}
return target.imageOverride;
}
if (config.imageRegistry) {
return rewriteRegistry(defaults.runtimeImage, config.imageRegistry);
}
return defaults.runtimeImage;
}
function rewriteRegistry(image: string, registry: string): string {
// image is like "ghcr.io/paperclipai/agent-runtime-claude:v1"
// we want to replace the first two path segments (host + org) with `registry`
const cleanRegistry = registry.replace(/\/+$/, "");
const colonIdx = image.lastIndexOf(":");
const tag = colonIdx >= 0 ? image.slice(colonIdx) : "";
const path = colonIdx >= 0 ? image.slice(0, colonIdx) : image;
const segments = path.split("/");
// Strip the host+org (first two segments), keep the image name
const imageName = segments.slice(2).join("/") || segments[segments.length - 1];
return `${cleanRegistry}/${imageName}${tag}`;
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./plugin.js";
@@ -0,0 +1,129 @@
import type { KubeClients } from "./kube-client.js";
import type { SandboxOrchestrator, SandboxStatus } from "./sandbox-orchestrator.js";
export class JobTimeoutError extends Error {
constructor(namespace: string, name: string, timeoutMs: number) {
super(`Job ${namespace}/${name} did not complete within ${timeoutMs}ms`);
this.name = "JobTimeoutError";
}
}
export async function createJob(
clients: KubeClients,
namespace: string,
manifest: Record<string, unknown>,
): Promise<{ uid: string }> {
const result = await clients.batch.createNamespacedJob({ namespace, body: manifest as never });
const uid = (result as { metadata?: { uid?: string } }).metadata?.uid;
if (!uid) throw new Error("Job created without a UID");
return { uid };
}
export type JobStatus = SandboxStatus;
export async function getJobStatus(
clients: KubeClients,
namespace: string,
name: string,
): Promise<JobStatus> {
const result = await clients.batch.readNamespacedJobStatus({ namespace, name });
const body = (result as Record<string, unknown>) ?? {};
const status = (body.status as Record<string, unknown>) ?? {};
const active = (status.active as number) ?? 0;
const succeeded = (status.succeeded as number) ?? 0;
const failed = (status.failed as number) ?? 0;
const conditions = (status.conditions as { type: string; status: string; reason?: string; message?: string }[]) ?? [];
const completed = conditions.find((c) => c.type === "Complete" && c.status === "True");
const failedCond = conditions.find((c) => c.type === "Failed" && c.status === "True");
if (failedCond || failed > 0) {
return { phase: "Failed", complete: false, active, succeeded, failed, reason: failedCond?.reason, message: failedCond?.message };
}
if (completed || succeeded > 0) {
return { phase: "Succeeded", complete: true, active, succeeded, failed };
}
if (active > 0) {
return { phase: "Running", complete: false, active, succeeded, failed };
}
return { phase: "Pending", complete: false, active, succeeded, failed };
}
export async function findPodForJob(
clients: KubeClients,
namespace: string,
jobName: string,
): Promise<string | null> {
const result = await clients.core.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
const items = ((result as { items?: { metadata?: { name?: string }; status?: { phase?: string } }[] }).items) ?? [];
const running = items.find((p) => p.status?.phase === "Running");
return (running ?? items[0])?.metadata?.name ?? null;
}
export async function streamPodLogs(
clients: KubeClients,
namespace: string,
podName: string,
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
): Promise<void> {
// V1 limitation: the Pod log API returns the container's combined log stream.
// Kubernetes does not preserve stdout/stderr channel separation after the
// container runtime writes logs, so the Job backend reports combined logs on
// stdout. The sandbox-cr backend uses exec and keeps streams separate.
const result = await clients.core.readNamespacedPodLog({ namespace, name: podName });
const text = readPodLogText(result);
if (text.length > 0) await onChunk("stdout", text);
}
function readPodLogText(result: unknown): string {
if (typeof result === "string") return result;
const body = (result as { body?: unknown })?.body;
return typeof body === "string" ? body : "";
}
export async function deleteJob(
clients: KubeClients,
namespace: string,
name: string,
): Promise<void> {
await clients.batch.deleteNamespacedJob({
namespace,
name,
propagationPolicy: "Foreground",
});
}
export async function waitForJobCompletion(
clients: KubeClients,
namespace: string,
name: string,
opts: { timeoutMs: number; pollMs?: number } = { timeoutMs: 120_000, pollMs: 2000 },
): Promise<JobStatus> {
const deadline = Date.now() + opts.timeoutMs;
const pollMs = opts.pollMs ?? 2000;
while (Date.now() < deadline) {
const status = await getJobStatus(clients, namespace, name);
if (status.phase === "Succeeded" || status.phase === "Failed") return status;
await sleep(pollMs);
}
throw new JobTimeoutError(namespace, name, opts.timeoutMs);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Job-backed conformance to SandboxOrchestrator. Plugin.ts imports THIS value
* (the swap point) — to use a different backend, swap this import for another
* module exposing a SandboxOrchestrator-shaped default export.
*/
export const jobOrchestrator: SandboxOrchestrator = {
claim: createJob,
getStatus: getJobStatus,
findPod: findPodForJob,
streamLogs: streamPodLogs,
release: deleteJob,
waitForCompletion: waitForJobCompletion,
};
@@ -0,0 +1,44 @@
import {
KubeConfig,
CoreV1Api,
BatchV1Api,
CustomObjectsApi,
NetworkingV1Api,
RbacAuthorizationV1Api,
} from "@kubernetes/client-node";
export interface CreateKubeConfigInput {
inCluster?: boolean;
kubeconfig?: string;
}
export function createKubeConfig(input: CreateKubeConfigInput): KubeConfig {
const kc = new KubeConfig();
if (input.inCluster) {
kc.loadFromCluster();
return kc;
}
if (input.kubeconfig && input.kubeconfig.trim().length > 0) {
kc.loadFromString(input.kubeconfig);
return kc;
}
throw new Error("createKubeConfig requires either inCluster=true or a kubeconfig string");
}
export interface KubeClients {
core: CoreV1Api;
batch: BatchV1Api;
custom: CustomObjectsApi;
networking: NetworkingV1Api;
rbac: RbacAuthorizationV1Api;
}
export function makeKubeClients(kc: KubeConfig): KubeClients {
return {
core: kc.makeApiClient(CoreV1Api),
batch: kc.makeApiClient(BatchV1Api),
custom: kc.makeApiClient(CustomObjectsApi),
networking: kc.makeApiClient(NetworkingV1Api),
rbac: kc.makeApiClient(RbacAuthorizationV1Api),
};
}
@@ -0,0 +1,135 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.kubernetes-sandbox-provider";
const PLUGIN_VERSION = "0.1.0-alpha.1";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Kubernetes Sandbox (alpha)",
description:
"Built on kubernetes-sigs/agent-sandbox (v1alpha1). ALPHA — expect breaking changes as the upstream CRD evolves. Falls back to stable batch/v1 Job mode for clusters without agent-sandbox installed. First-party Paperclip sandbox-provider plugin for Kubernetes.",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: {
worker: "./dist/worker.js",
},
environmentDrivers: [
{
driverKey: "kubernetes",
kind: "sandbox_provider",
displayName: "Kubernetes",
description:
"Dispatches agent runs in per-tenant Kubernetes namespaces. Default backend (sandbox-cr, alpha) uses kubernetes-sigs/agent-sandbox for multi-command exec; fallback backend (job) uses stable batch/v1 Job for clusters without agent-sandbox installed.",
configSchema: {
type: "object",
properties: {
inCluster: {
type: "boolean",
description:
"When true, the plugin uses the in-pod ServiceAccount credentials. Requires paperclip-server to be running inside the target cluster.",
},
kubeconfig: {
type: "string",
format: "secret-ref",
pattern: "\\S",
description:
"Inline kubeconfig YAML. Paste a kubeconfig or an existing Paperclip secret reference; pasted values are stored as company secrets.",
},
namespacePrefix: {
type: "string",
maxLength: 20,
description: "Prefix for the per-company tenant namespace (default: paperclip-).",
},
paperclipServerNamespace: {
type: "string",
maxLength: 63,
pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
description:
"Namespace where paperclip-server pods run. Used by generated egress policies so agent pods can call back to the server (default: paperclip).",
},
companySlug: {
type: "string",
maxLength: 43,
description: "Override the auto-derived company slug used in the tenant namespace name.",
},
imageRegistry: {
type: "string",
description: "Override the default registry for agent runtime images (default: ghcr.io/paperclipai).",
},
imageAllowList: {
type: "array",
items: { type: "string" },
description:
"Glob patterns of allowed `target.imageOverride` values. Empty list = no override permitted.",
},
imagePullSecrets: {
type: "array",
items: { type: "string" },
description: "Names of pre-created Docker image pull secrets in the tenant namespace.",
},
egressAllowFqdns: {
type: "array",
items: { type: "string" },
description:
"Additional FQDNs to allow egress to from agent pods. Adapter-default FQDNs (e.g. api.anthropic.com) are added automatically.",
},
egressAllowCidrs: {
type: "array",
items: { type: "string" },
description: "Additional CIDRs to allow HTTPS egress to from agent pods. CIDR egress is restricted to TCP port 443.",
},
egressMode: {
type: "string",
enum: ["standard", "cilium"],
description:
"Network policy mode. `standard` uses NetworkPolicy and allows public HTTPS when adapter FQDNs are configured; `cilium` enables exact FQDN egress filtering via CiliumNetworkPolicy.",
},
runtimeClassName: {
type: "string",
description:
"Optional RuntimeClass for pod isolation (e.g. `kata-fc` for Firecracker-backed microVMs). Cluster must have the RuntimeClass installed.",
},
serviceAccountAnnotations: {
type: "object",
additionalProperties: { type: "string" },
description:
"Annotations applied to the per-tenant ServiceAccount (e.g. `eks.amazonaws.com/role-arn` for IRSA).",
},
jobTtlSecondsAfterFinished: {
type: "integer",
minimum: 0,
description: "Seconds after a Job completes before it is garbage-collected (default: 900).",
},
podActivityDeadlineSec: {
type: "integer",
minimum: 1,
description: "Hard ceiling on a single run's wall-clock time (default: 3600).",
},
adapterType: {
type: "string",
description:
"The adapter type that Jobs in this environment will run (e.g. `claude_local`, `codex_local`). Defaults to `claude_local`. Each environment is bound to one adapter; create multiple environments for different adapters.",
},
backend: {
type: "string",
enum: ["sandbox-cr", "job"],
description:
"sandbox-cr (default, alpha — requires kubernetes-sigs/agent-sandbox installed) | job (stable fallback — batch/v1 Job, one-shot entrypoint, no multi-command exec)",
},
},
anyOf: [
{
properties: { inCluster: { const: true } },
required: ["inCluster"],
},
{ required: ["kubeconfig"] },
],
},
},
],
};
export default manifest;
@@ -0,0 +1,102 @@
export interface BuildNetworkPolicyInput {
namespace: string;
paperclipServerNamespace: string;
egressAllowFqdns: string[];
egressAllowCidrs: string[];
}
const PUBLIC_IPV4_EXCEPTIONS = [
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
];
// Design note: the deny-all baseline blocks all ingress to agent pods.
// Paperclip-server does NOT push to agent pods — the agent shim makes
// outbound calls to paperclip-server via the egress allow-list (port 3100).
// This pull/callback model means no ingress rule is needed. If a future
// feature requires server→agent push (e.g. forced shutdown, live exec),
// add a targeted ingress rule here scoped to the paperclip-server pod
// selector.
//
// Standard Kubernetes NetworkPolicy cannot express FQDN allow-lists. When
// adapter defaults require FQDN egress, keep runs functional by allowing public
// IPv4 HTTPS while excluding private/link-local ranges. Operators who need
// exact FQDN enforcement should use egressMode="cilium".
export function buildNetworkPolicyManifests(input: BuildNetworkPolicyInput): Record<string, unknown>[] {
const fqdnsRequirePublicHttpsFallback = input.egressAllowFqdns.length > 0;
const denyAll = {
apiVersion: "networking.k8s.io/v1",
kind: "NetworkPolicy",
metadata: {
name: "paperclip-deny-all",
namespace: input.namespace,
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
},
spec: {
podSelector: {},
policyTypes: ["Ingress", "Egress"],
},
};
const egressAllow: Record<string, unknown> = {
apiVersion: "networking.k8s.io/v1",
kind: "NetworkPolicy",
metadata: {
name: "paperclip-egress-allow",
namespace: input.namespace,
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
},
spec: {
podSelector: { matchLabels: { "paperclip.io/role": "agent" } },
policyTypes: ["Egress"],
egress: [
{
to: [
{
namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": "kube-system" } },
podSelector: { matchLabels: { "k8s-app": "kube-dns" } },
},
],
ports: [
{ protocol: "UDP", port: 53 },
{ protocol: "TCP", port: 53 },
],
},
{
to: [
{
namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": input.paperclipServerNamespace } },
podSelector: { matchLabels: { app: "paperclip-server" } },
},
],
ports: [{ protocol: "TCP", port: 3100 }],
},
...(fqdnsRequirePublicHttpsFallback
? [
{
to: [
{
ipBlock: {
cidr: "0.0.0.0/0",
except: PUBLIC_IPV4_EXCEPTIONS,
},
},
],
ports: [{ protocol: "TCP", port: 443 }],
},
]
: []),
...input.egressAllowCidrs.map((cidr) => ({
to: [{ ipBlock: { cidr } }],
ports: [{ protocol: "TCP", port: 443 }],
})),
],
},
};
return [denyAll, egressAllow];
}
@@ -0,0 +1,700 @@
import { randomBytes } from "node:crypto";
import { definePlugin } from "@paperclipai/plugin-sdk";
import type {
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentLease,
PluginEnvironmentProbeParams,
PluginEnvironmentProbeResult,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
import {
kubernetesProviderConfigSchema,
type KubernetesProviderConfig,
type KubernetesLeaseMetadata,
} from "./types.js";
import { createKubeConfig, makeKubeClients } from "./kube-client.js";
import { getAdapterDefaults } from "./adapter-defaults.js";
import { resolveImage } from "./image-allowlist.js";
import { buildJobManifest } from "./pod-spec-builder.js";
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
import { ensureTenant } from "./tenant-orchestrator.js";
import { createPerRunSecret } from "./secret-manager.js";
import { FastUploadInterceptor } from "./upload-interceptor.js";
import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
import {
sandboxCrOrchestrator,
SandboxCrTimeoutError,
} from "./sandbox-cr-orchestrator.js";
import { execInPod } from "./pod-exec.js";
import { shellQuoteArg } from "./shell-utils.js";
import {
deriveCompanySlug,
deriveNamespaceName,
newRunUlidDns,
paperclipLabels,
} from "./utils.js";
// Name of the ServiceAccount created inside each tenant namespace by ensureTenant.
const TENANT_SERVICE_ACCOUNT = "paperclip-tenant-sa";
// Resource quota defaults applied to every tenant namespace (M4b; tunable via
// config in a future milestone).
const DEFAULT_RESOURCE_QUOTA = {
pods: "20",
requestsCpu: "10",
requestsMemory: "20Gi",
limitsCpu: "20",
limitsMemory: "40Gi",
};
const uploadInterceptorsByLease = new Map<string, FastUploadInterceptor>();
function getOrCreateUploadInterceptor(leaseId: string): FastUploadInterceptor {
let interceptor = uploadInterceptorsByLease.get(leaseId);
if (!interceptor) {
interceptor = new FastUploadInterceptor();
uploadInterceptorsByLease.set(leaseId, interceptor);
}
return interceptor;
}
function extractShellScript(
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
): string | null {
const command = typeof params.command === "string" ? params.command.trim() : "";
const args = Array.isArray(params.args) ? params.args : [];
const isShell = command === "sh" || command === "bash" || command.endsWith("/sh") || command.endsWith("/bash");
if (isShell && args[0] === "-c" && typeof args[1] === "string") {
return args[1];
}
return null;
}
function deriveTenantNamespace(config: KubernetesProviderConfig, companyId: string): string {
// TODO: future versions could thread companyName through AcquireLeaseParams
// to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
const slug = config.companySlug ?? deriveCompanySlug(companyId);
return deriveNamespaceName(config.namespacePrefix, slug);
}
/**
* Reads adapter env keys (e.g. ANTHROPIC_API_KEY) from the current process
* environment. The plugin worker runs inside paperclip-server's pod, which has
* these vars injected at deploy time.
*
* M4b approach: env vars sourced from process.env at acquire time.
* TODO: future milestones may thread per-run secrets differently (e.g. via
* a secret store reference on the environment config).
*/
export function extractAdapterEnvFromProcess(
envKeys: string[],
warn: (message: string) => void = console.warn,
): Record<string, string> {
const out: Record<string, string> = {};
const missing: string[] = [];
for (const k of envKeys) {
const v = process.env[k];
if (v !== undefined) {
out[k] = v;
} else {
missing.push(k);
}
}
if (missing.length > 0) {
warn(
`[plugin-kubernetes] adapter environment variable(s) missing from plugin worker process: ${missing.join(", ")}. Agent pods may fail provider authentication unless these keys are optional for the selected adapter.`,
);
}
return out;
}
export function buildSandboxExecCommand(
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
): string[] {
const command = typeof params.command === "string" ? params.command.trim() : "";
const args = Array.isArray(params.args) ? params.args : [];
if (command.length > 0 && args.length > 0) {
return [command, ...args];
}
if (command.length > 0) {
return ["/bin/sh", "-lc", command];
}
if (args.length > 0) {
return ["/bin/sh", "-lc", args.map(shellQuoteArg).join(" ")];
}
return ["/bin/sh", "-l"];
}
export function deriveUploadTargetDir(targetPath: string): string {
const slashIndex = targetPath.lastIndexOf("/");
return slashIndex >= 0 ? targetPath.slice(0, slashIndex) || "/" : ".";
}
export function buildSandboxExecShellCommand(
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
): string {
if (typeof params.command === "string" && params.command.trim().length > 0) {
return params.command;
}
return params.args?.map(shellQuoteArg).join(" ") ?? "";
}
function generateBootstrapToken(): string {
// TODO: paperclip-server's actual callback auth scheme is separate and is
// out of M4b scope. This per-run random token is stored in the per-run
// Secret and consumed by paperclip-agent-shim for initial registration.
return randomBytes(32).toString("hex");
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info("Kubernetes sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "Kubernetes sandbox provider plugin healthy" };
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
if (!parsed.success) {
return {
ok: false,
errors: parsed.error.issues.map((i) => i.message),
};
}
const warnings: string[] = [];
const cfg = parsed.data;
const adapterDefaults = getAdapterDefaults(cfg.adapterType);
const totalFqdns = [...adapterDefaults.allowFqdns, ...cfg.egressAllowFqdns];
if (cfg.egressMode === "standard" && totalFqdns.length > 0) {
warnings.push(
`egressMode=standard cannot enforce FQDN-based egress rules for ${totalFqdns.join(", ")}. Agent pods will get public IPv4 HTTPS egress with private/link-local ranges excluded. Switch egressMode to "cilium" for exact FQDN enforcement.`,
);
}
return { ok: true, normalizedConfig: cfg as Record<string, unknown>, warnings: warnings.length > 0 ? warnings : undefined };
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
if (!parsed.success) {
return {
ok: false,
summary: "Invalid Kubernetes provider configuration.",
metadata: {
errors: parsed.error.issues.map((i) => i.message),
},
};
}
const config = parsed.data;
const namespace = deriveTenantNamespace(config, params.companyId);
try {
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
// Reachability check: list pods in the tenant namespace. If the namespace
// doesn't exist yet this will throw a 404 which we treat as "reachable
// but namespace not provisioned" — still a successful probe.
try {
await clients.core.listNamespacedPod({ namespace });
} catch (err) {
const code = (err as { code?: number; statusCode?: number }).code
?? (err as { code?: number; statusCode?: number }).statusCode;
if (code !== 404) throw err;
// 404 means namespace doesn't exist yet — cluster is reachable.
}
return {
ok: true,
summary: `Kubernetes cluster reachable. Tenant namespace: ${namespace}.`,
metadata: { namespace, provider: "kubernetes" },
};
} catch (err) {
return {
ok: false,
summary: "Kubernetes cluster probe failed.",
metadata: {
namespace,
provider: "kubernetes",
error: err instanceof Error ? err.message : String(err),
},
};
}
},
async onEnvironmentAcquireLease(
params: PluginEnvironmentAcquireLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace = deriveTenantNamespace(config, params.companyId);
// Emit a runtime warning if FQDNs are configured but egressMode=standard
// cannot enforce them. Mirrors the validateConfig warning so operators see
// it in paperclip-server logs even if they missed the validation step.
const adapterDefaultsForWarn = getAdapterDefaults(config.adapterType);
const totalFqdnsForWarn = [...adapterDefaultsForWarn.allowFqdns, ...config.egressAllowFqdns];
if (config.egressMode === "standard" && totalFqdnsForWarn.length > 0) {
// The SDK does not currently thread ctx.logger into environment hooks.
// Keep this explicit so operators still see the standard-mode egress
// trade-off in raw worker logs.
// eslint-disable-next-line no-console
console.warn(
`[plugin-kubernetes] egressMode=standard cannot enforce FQDN-based egress rules for ${totalFqdnsForWarn.join(", ")}. Agent pods will get public IPv4 HTTPS egress with private/link-local ranges excluded. Switch egressMode to "cilium" for exact FQDN enforcement.`,
);
}
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
// Ensure the tenant namespace and all its RBAC / network policy resources
// exist before we try to create the Job.
const adapterDefaults = getAdapterDefaults(config.adapterType);
await ensureTenant(clients, {
namespace,
companyId: params.companyId,
paperclipServerNamespace: config.paperclipServerNamespace,
serviceAccountAnnotations: config.serviceAccountAnnotations,
egressMode: config.egressMode,
egressAllowFqdns: [...adapterDefaults.allowFqdns, ...config.egressAllowFqdns],
egressAllowCidrs: config.egressAllowCidrs,
resourceQuota: DEFAULT_RESOURCE_QUOTA,
});
const jobName = `pc-${newRunUlidDns()}`;
const secretName = `${jobName}-env`;
// TODO: use params.runId as stand-in for agentId in labels; future
// versions will have a dedicated agentId on AcquireLeaseParams.
const labels = paperclipLabels({
runId: params.runId,
agentId: params.runId,
companyId: params.companyId,
adapterType: config.adapterType,
});
const image = resolveImage(
{ imageOverride: null },
adapterDefaults,
{ imageAllowList: config.imageAllowList, imageRegistry: config.imageRegistry },
);
// Pick the orchestrator and build the appropriate manifest based on backend.
const isSandboxCrBackend = config.backend === "sandbox-cr";
const orchestrator = isSandboxCrBackend ? sandboxCrOrchestrator : jobOrchestrator;
const manifest = isSandboxCrBackend
? buildSandboxCrManifest({
namespace,
sandboxName: jobName,
adapterType: config.adapterType,
image,
envSecretName: secretName,
serviceAccountName: TENANT_SERVICE_ACCOUNT,
labels,
resources: config.defaultResources ?? {},
runtimeClassName: config.runtimeClassName,
imagePullSecrets: config.imagePullSecrets,
})
: buildJobManifest({
namespace,
jobName,
adapterType: config.adapterType,
image,
envSecretName: secretName,
serviceAccountName: TENANT_SERVICE_ACCOUNT,
labels,
resources: config.defaultResources ?? {},
runtimeClassName: config.runtimeClassName,
activeDeadlineSec: config.podActivityDeadlineSec,
ttlSecondsAfterFinished: config.jobTtlSecondsAfterFinished,
imagePullSecrets: config.imagePullSecrets,
});
const { uid: ownerUid } = await orchestrator.claim(clients, namespace, manifest);
// M4b: adapter env vars are sourced from the plugin worker's own process
// environment (paperclip-server pod has them injected at deploy time).
const adapterEnv = extractAdapterEnvFromProcess(adapterDefaults.envKeys);
const bootstrapToken = generateBootstrapToken();
// Secret ownerRef: for job backend, the Job owns the Secret (cascade delete).
// For sandbox-cr backend, the Sandbox CR owns the Secret.
// NOTE: For sandbox-cr, if the Secret outlives the Sandbox due to a cluster
// quirk, the release() call will still clean it up via namespace GC or
// explicit delete in a future milestone.
try {
await createPerRunSecret(clients, {
namespace,
secretName,
runId: params.runId,
ownerKind: isSandboxCrBackend ? "Sandbox" : "Job",
ownerApiVersion: isSandboxCrBackend ? "agents.x-k8s.io/v1alpha1" : "batch/v1",
ownerName: jobName,
ownerUid,
bootstrapToken,
adapterEnv,
});
const podName = await orchestrator.findPod(clients, namespace, jobName);
const leaseMetadata: KubernetesLeaseMetadata = {
namespace,
jobName,
podName,
secretName,
phase: "Pending",
backend: config.backend,
};
return {
providerLeaseId: jobName,
metadata: leaseMetadata as unknown as Record<string, unknown>,
};
} catch (err) {
try {
await orchestrator.release(clients, namespace, jobName);
} catch (cleanupErr) {
throw new Error(
`Kubernetes lease setup failed and cleanup also failed: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
{ cause: err },
);
}
throw err;
}
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
// The agent pod already has /workspace mounted as an emptyDir at pod
// scheduling time (see pod-spec-builder). Nothing to provision here —
// we just hand back the cwd. Honor a caller-supplied remotePath if set.
const cwd =
params.workspace.remotePath && params.workspace.remotePath.trim().length > 0
? params.workspace.remotePath.trim()
: "/workspace";
return {
cwd,
metadata: {
provider: "kubernetes",
remoteCwd: cwd,
},
};
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
typeof params.leaseMetadata?.namespace === "string"
? params.leaseMetadata.namespace
: deriveTenantNamespace(config, params.companyId);
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
const leaseBackend =
typeof params.leaseMetadata?.backend === "string"
? (params.leaseMetadata.backend as "sandbox-cr" | "job")
: config.backend;
const releaseOrchestrator =
leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
uploadInterceptorsByLease.delete(params.providerLeaseId);
try {
await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
} catch (err) {
// If the resource is already gone (404), that's fine.
const code = (err as { code?: number; statusCode?: number }).code
?? (err as { code?: number; statusCode?: number }).statusCode;
if (code !== 404) throw err;
}
},
async onEnvironmentExecute(
params: PluginEnvironmentExecuteParams,
): Promise<PluginEnvironmentExecuteResult> {
const { lease, timeoutMs } = params;
if (!lease.providerLeaseId) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
};
}
const config = kubernetesProviderConfigSchema.parse(params.config);
const namespace =
typeof lease.metadata?.namespace === "string"
? lease.metadata.namespace
: deriveTenantNamespace(config, params.companyId);
// Determine which backend this lease was created with.
const leaseBackend =
typeof lease.metadata?.backend === "string"
? (lease.metadata.backend as "sandbox-cr" | "job")
: config.backend;
const kc = createKubeConfig({
inCluster: config.inCluster,
kubeconfig: config.kubeconfig,
});
const clients = makeKubeClients(kc);
const effectiveTimeoutMs =
typeof timeoutMs === "number" && timeoutMs > 0
? timeoutMs
: config.podActivityDeadlineSec * 1000;
if (leaseBackend === "sandbox-cr") {
// ── Sandbox-CR backend ──────────────────────────────────────────────────
// 1. Ensure the Sandbox pod is Ready (wait if needed).
// 2. Exec the command into the running pod.
// 3. Return exec result directly (no log scraping needed).
const executeStartedAt = Date.now();
let podName =
typeof lease.metadata?.podName === "string" && lease.metadata.podName
? lease.metadata.podName
: null;
// Wait for pod Ready if we don't have a pod name yet (or as a health check).
try {
await sandboxCrOrchestrator.waitForCompletion(
clients,
namespace,
lease.providerLeaseId,
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
);
} catch (err) {
if (err instanceof SandboxCrTimeoutError) {
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: `Sandbox pod did not become Ready within ${effectiveTimeoutMs}ms`,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
},
};
}
throw err;
}
// Resolve pod name (may now be populated in Sandbox status).
if (!podName) {
podName = await sandboxCrOrchestrator.findPod(
clients,
namespace,
lease.providerLeaseId,
);
}
if (!podName) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "Sandbox pod is Ready but podName could not be resolved.",
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
},
};
}
const remainingTimeoutMs = Math.max(1, effectiveTimeoutMs - (Date.now() - executeStartedAt));
const shellScript = extractShellScript(params);
if (shellScript) {
const decision = getOrCreateUploadInterceptor(lease.providerLeaseId).decide(shellScript);
if (decision.action === "ack") {
return {
exitCode: 0,
timedOut: false,
stdout: "",
stderr: "",
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
fastUpload: "ack",
},
};
}
if (decision.action === "flush") {
const base64Body = decision.flush.payload.toString("base64");
const dir = deriveUploadTargetDir(decision.flush.targetPath);
const script =
`mkdir -p ${shellQuoteArg(dir)} && ` +
`base64 -d > ${shellQuoteArg(decision.flush.targetPath)}`;
const flushResult = await execInPod(
kc,
namespace,
podName,
"agent",
["/bin/sh", "-c", script],
base64Body,
remainingTimeoutMs,
);
return {
exitCode: flushResult.exitCode,
timedOut: flushResult.timedOut,
stdout: flushResult.stdout,
stderr: flushResult.stderr,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
fastUpload: "flush",
uploadedBytes: decision.flush.payload.length,
},
};
}
if (decision.action === "error") {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: decision.message,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
fastUpload: "error",
},
};
}
}
const execCommand = buildSandboxExecCommand(params);
const execResult = await execInPod(
kc,
namespace,
podName,
"agent",
execCommand,
typeof params.stdin === "string" ? params.stdin : undefined,
remainingTimeoutMs,
);
return {
exitCode: execResult.exitCode,
timedOut: execResult.timedOut,
stdout: execResult.stdout,
stderr: execResult.stderr,
metadata: {
provider: "kubernetes",
backend: "sandbox-cr",
namespace,
sandboxName: lease.providerLeaseId,
podName,
},
};
} else {
// ── Job backend (legacy / stable fallback) ──────────────────────────────
// The container entrypoint is baked into the Job spec (Tini + paperclip-agent-shim).
// We do NOT re-exec command/args — instead we wait for the Job to finish
// and collect its logs.
//
// params.command / params.args / params.stdin are intentionally ignored.
let status;
let timedOut = false;
try {
status = await jobOrchestrator.waitForCompletion(
clients,
namespace,
lease.providerLeaseId,
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
);
} catch (err) {
if (err instanceof JobTimeoutError) {
timedOut = true;
status = null;
} else {
throw err;
}
}
// Collect logs from the pod.
const podName =
typeof lease.metadata?.podName === "string"
? lease.metadata.podName
: await jobOrchestrator.findPod(
clients,
namespace,
lease.providerLeaseId,
);
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
if (podName) {
await jobOrchestrator.streamLogs(
clients,
namespace,
podName,
async (stream, text) => {
if (stream === "stdout") stdoutChunks.push(text);
else stderrChunks.push(text);
},
);
}
return {
exitCode: timedOut ? null : status?.phase === "Succeeded" ? 0 : 1,
timedOut,
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
metadata: {
provider: "kubernetes",
backend: "job",
namespace,
jobName: lease.providerLeaseId,
podName: podName ?? null,
phase: status?.phase ?? null,
},
};
}
},
});
export default plugin;
@@ -0,0 +1,186 @@
/**
* Exec a command inside a running pod container using the Kubernetes exec API.
*
* Uses @kubernetes/client-node's Exec class, which opens a WebSocket to the
* kube-apiserver and streams stdout/stderr. The statusCallback receives a V1Status
* with status="Success" or status="Failure" + details.causes[{reason:"ExitCode"}].
*
* NOTE: tty=false so stdout and stderr arrive on separate channels. If tty=true
* were used, they would be merged onto stdout and the exit code would not be
* reliable from the status callback on older cluster versions.
*/
import { Exec } from "@kubernetes/client-node";
import { PassThrough } from "node:stream";
import type { KubeConfig } from "@kubernetes/client-node";
import { shellQuoteArg } from "./shell-utils.js";
type WebSocketLike = {
close(): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: Error) => void): void;
};
export interface ExecInPodResult {
exitCode: number;
timedOut: boolean;
stdout: string;
stderr: string;
}
export async function execInPod(
kc: KubeConfig,
namespace: string,
podName: string,
containerName: string,
command: string[],
stdin?: string | Buffer,
timeoutMs?: number,
): Promise<ExecInPodResult> {
const exec = new Exec(kc);
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
const stdinPayload: Buffer | null =
Buffer.isBuffer(stdin) ? stdin
: typeof stdin === "string" && stdin.length > 0 ? Buffer.from(stdin, "utf-8")
: null;
const stdinStream: PassThrough | null = stdinPayload ? new PassThrough() : null;
const effectiveCommand = stdinPayload
? ["/bin/sh", "-c", `head -c ${stdinPayload.length} | ${command.map(shellQuoteArg).join(" ")}`]
: command;
let stdoutData = "";
let stderrData = "";
stdoutStream.on("data", (chunk: Buffer) => {
stdoutData += chunk.toString("utf-8");
});
stderrStream.on("data", (chunk: Buffer) => {
stderrData += chunk.toString("utf-8");
});
stdoutStream.on("error", () => {});
stderrStream.on("error", () => {});
return await new Promise<ExecInPodResult>(
(resolve, reject) => {
let ws: WebSocketLike | null = null;
let settled = false;
let pendingResult: Omit<ExecInPodResult, "stdout" | "stderr"> | null = null;
let stdoutEnded = false;
let stderrEnded = false;
const timeout =
typeof timeoutMs === "number" && timeoutMs > 0
? setTimeout(() => {
finishWithTransportFailure(`Kubernetes exec timed out after ${timeoutMs}ms`, true);
}, timeoutMs)
: null;
const finish = (result: ExecInPodResult) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
try {
ws?.close();
} catch {
// Ignore best-effort close failures.
}
resolve(result);
};
const finishWithTransportFailure = (message: string, timedOut = false) => {
const separator = stderrData.length > 0 && !stderrData.endsWith("\n") ? "\n" : "";
finish({
exitCode: 1,
timedOut,
stdout: stdoutData,
stderr: `${stderrData}${separator}${message}`,
});
};
const tryFinish = () => {
if (settled || !pendingResult || !stdoutEnded || !stderrEnded) return;
finish({
...pendingResult,
stdout: stdoutData,
stderr: stderrData,
});
};
const endOutputStreams = () => {
if (!stdoutStream.writableEnded) stdoutStream.end();
if (!stderrStream.writableEnded) stderrStream.end();
};
stdoutStream.on("end", () => {
stdoutEnded = true;
tryFinish();
});
stderrStream.on("end", () => {
stderrEnded = true;
tryFinish();
});
const websocketPromise = exec
.exec(
namespace,
podName,
containerName,
effectiveCommand,
stdoutStream,
stderrStream,
stdinStream,
false, // tty=false: keep stdout/stderr on separate channels
(status) => {
// status.status is "Success" | "Failure"
if (status.status === "Success") {
pendingResult = { exitCode: 0, timedOut: false };
endOutputStreams();
tryFinish();
return;
}
// On failure, the exit code surfaces via
// status.details?.causes[].{reason:"ExitCode", message:"<N>"}
const causes = status.details?.causes ?? [];
const exitCodeCause = causes.find(
(c: { reason?: string; message?: string }) =>
c.reason === "ExitCode",
);
const exitCode = exitCodeCause?.message
? Number(exitCodeCause.message)
: 1;
pendingResult = { exitCode, timedOut: false };
endOutputStreams();
tryFinish();
},
);
websocketPromise
.then((webSocket) => {
ws = webSocket as WebSocketLike;
if (settled) {
try {
ws.close();
} catch {
// Ignore best-effort close failures.
}
return;
}
if (stdinStream && stdinPayload) {
stdinStream.end(stdinPayload);
}
ws.on("close", (code: number, reason: Buffer) => {
if (settled || pendingResult) return;
const reasonText = reason.length > 0 ? `: ${reason.toString("utf-8")}` : "";
finishWithTransportFailure(`Kubernetes exec websocket closed before status frame (${code})${reasonText}`);
});
ws.on("error", (err: Error) => {
if (settled || pendingResult) return;
finishWithTransportFailure(`Kubernetes exec websocket failed before status frame: ${err.message}`);
});
})
.catch((err) => {
if (settled) return;
if (timeout) clearTimeout(timeout);
reject(err);
});
},
);
}
@@ -0,0 +1,94 @@
export interface BuildJobManifestInput {
namespace: string;
jobName: string;
adapterType: string;
image: string;
envSecretName: string;
serviceAccountName: string;
labels: Record<string, string>;
resources: {
requests?: { cpu?: string; memory?: string };
limits?: { cpu?: string; memory?: string };
};
runtimeClassName?: string;
activeDeadlineSec: number;
ttlSecondsAfterFinished: number;
imagePullSecrets?: string[];
}
export function buildJobManifest(input: BuildJobManifestInput): Record<string, unknown> {
const podLabels = {
...input.labels,
"paperclip.io/role": "agent",
};
return {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: input.jobName,
namespace: input.namespace,
labels: { ...input.labels },
},
spec: {
backoffLimit: 0,
ttlSecondsAfterFinished: input.ttlSecondsAfterFinished,
activeDeadlineSeconds: input.activeDeadlineSec,
template: {
metadata: { labels: podLabels },
spec: {
serviceAccountName: input.serviceAccountName,
// Agent containers call back to paperclip-server via HTTPS egress;
// they never call the Kubernetes API, so mounting an SA token is
// unnecessary attack surface.
automountServiceAccountToken: false,
restartPolicy: "Never",
...(input.runtimeClassName ? { runtimeClassName: input.runtimeClassName } : {}),
...(input.imagePullSecrets && input.imagePullSecrets.length > 0
? { imagePullSecrets: input.imagePullSecrets.map((name) => ({ name })) }
: {}),
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
runAsGroup: 1000,
fsGroup: 1000,
fsGroupChangePolicy: "OnRootMismatch",
seccompProfile: { type: "RuntimeDefault" },
},
containers: [
{
name: "agent",
image: input.image,
imagePullPolicy: "IfNotPresent",
command: ["/usr/bin/tini", "--", "/usr/local/bin/paperclip-agent-shim"],
envFrom: [{ secretRef: { name: input.envSecretName } }],
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
runAsGroup: 1000,
readOnlyRootFilesystem: true,
allowPrivilegeEscalation: false,
capabilities: { drop: ["ALL"] },
},
resources: {
requests: input.resources.requests ?? { cpu: "250m", memory: "512Mi" },
limits: input.resources.limits ?? { cpu: "2", memory: "4Gi" },
},
volumeMounts: [
{ name: "workspace", mountPath: "/workspace" },
{ name: "home", mountPath: "/home/paperclip" },
{ name: "cache", mountPath: "/home/paperclip/.cache" },
{ name: "tmp", mountPath: "/tmp" },
],
},
],
volumes: [
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
{ name: "home", emptyDir: { sizeLimit: "1Gi" } },
{ name: "cache", emptyDir: { sizeLimit: "1Gi" } },
{ name: "tmp", emptyDir: { sizeLimit: "2Gi" } },
],
},
},
},
};
}
@@ -0,0 +1,136 @@
/**
* Builds a kubernetes-sigs/agent-sandbox Sandbox CR manifest.
*
* The Sandbox CR creates a long-lived pod (sleep infinity entrypoint) into
* which paperclip-server can exec arbitrary commands. This solves the
* architectural mismatch with the batch/v1 Job backend, which only supports
* a single one-shot entrypoint — not the multi-command adapter-install pattern
* used by paperclip-server.
*
* Security baseline is identical to buildJobManifest (pod-spec-builder.ts):
* non-root, drop ALL caps, read-only rootFS, Tini PID 1, seccomp
* RuntimeDefault, fsGroupChangePolicy OnRootMismatch, automountSAToken=false.
*
* NOTE: paperclip-server runs OUTSIDE the cluster, so we cannot set ownerReferences
* on the Sandbox CR (the owner would need to be an in-cluster resource). The
* release path is explicit delete via sandboxCrOrchestrator.release().
*/
export interface BuildSandboxCrManifestInput {
namespace: string;
sandboxName: string;
adapterType: string;
image: string;
envSecretName: string;
serviceAccountName: string;
labels: Record<string, string>;
resources: {
requests?: { cpu?: string; memory?: string };
limits?: { cpu?: string; memory?: string };
};
runtimeClassName?: string;
imagePullSecrets?: string[];
}
export function buildSandboxCrManifest(
input: BuildSandboxCrManifestInput,
): Record<string, unknown> {
const podLabels: Record<string, string> = {
...input.labels,
"paperclip.io/role": "agent",
};
return {
apiVersion: "agents.x-k8s.io/v1alpha1",
kind: "Sandbox",
metadata: {
name: input.sandboxName,
namespace: input.namespace,
labels: { ...input.labels },
// No ownerReferences: paperclip-server is out-of-cluster. Release is
// explicit delete.
},
spec: {
podTemplate: {
metadata: {
labels: podLabels,
},
spec: {
serviceAccountName: input.serviceAccountName,
// Agent containers call back to paperclip-server via HTTPS egress;
// they never call the Kubernetes API, so mounting an SA token is
// unnecessary attack surface.
automountServiceAccountToken: false,
// Sandbox controller requires restartPolicy: Always so the pod
// stays running between exec calls.
restartPolicy: "Always",
...(input.runtimeClassName
? { runtimeClassName: input.runtimeClassName }
: {}),
...(input.imagePullSecrets && input.imagePullSecrets.length > 0
? {
imagePullSecrets: input.imagePullSecrets.map((name) => ({
name,
})),
}
: {}),
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
runAsGroup: 1000,
fsGroup: 1000,
fsGroupChangePolicy: "OnRootMismatch",
seccompProfile: { type: "RuntimeDefault" },
},
containers: [
{
name: "agent",
image: input.image,
imagePullPolicy: "IfNotPresent",
// sleep infinity keeps the pod running; paperclip-server execs
// commands into it via Kubernetes exec API. Tini as PID 1 for
// proper signal forwarding and zombie reaping.
command: [
"/usr/bin/tini",
"--",
"/bin/sh",
"-c",
"sleep infinity",
],
envFrom: [{ secretRef: { name: input.envSecretName } }],
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
runAsGroup: 1000,
readOnlyRootFilesystem: true,
allowPrivilegeEscalation: false,
capabilities: { drop: ["ALL"] },
},
resources: {
requests: input.resources.requests ?? {
cpu: "250m",
memory: "512Mi",
},
limits: input.resources.limits ?? {
cpu: "2",
memory: "4Gi",
},
},
volumeMounts: [
{ name: "workspace", mountPath: "/workspace" },
{ name: "home", mountPath: "/home/paperclip" },
{ name: "cache", mountPath: "/home/paperclip/.cache" },
{ name: "tmp", mountPath: "/tmp" },
],
},
],
volumes: [
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
{ name: "home", emptyDir: { sizeLimit: "1Gi" } },
{ name: "cache", emptyDir: { sizeLimit: "1Gi" } },
{ name: "tmp", emptyDir: { sizeLimit: "2Gi" } },
],
},
},
},
};
}
@@ -0,0 +1,291 @@
/**
* SandboxOrchestrator implementation backed by the kubernetes-sigs/agent-sandbox
* Sandbox CRD (agents.x-k8s.io/v1alpha1).
*
* The Sandbox CR creates a long-lived pod that paperclip-server can exec into
* for multi-command adapter-install workflows — the key architectural win over
* the batch/v1 Job backend.
*
* Key semantic differences from jobOrchestrator:
* - claim() creates a Sandbox CR via CustomObjectsApi instead of a batch Job
* - getStatus() maps Sandbox phase (Pending|Ready|Terminating|Failed) to SandboxStatus
* - findPod() reads status.podName from the Sandbox CR (falls back to label query)
* - waitForCompletion() means "wait until pod is Ready to exec" NOT "wait until
* workload finishes". The Sandbox pod runs sleep infinity; execution completion
* is tracked by the individual execInPod() calls.
* - release() deletes the Sandbox CR with Foreground propagation (controller
* tears down the underlying pod).
*
* NOTE: streamLogs() is provided for interface conformance but is limited —
* the sleep-infinity pod has no meaningful stdout. Callers in execute mode
* should use execInPod() and capture its stdout/stderr directly.
*/
import type { KubeClients } from "./kube-client.js";
import type { SandboxOrchestrator, SandboxStatus } from "./sandbox-orchestrator.js";
const SANDBOX_GROUP = "agents.x-k8s.io";
const SANDBOX_VERSION = "v1alpha1";
const SANDBOX_PLURAL = "sandboxes";
export class SandboxCrTimeoutError extends Error {
constructor(namespace: string, name: string, timeoutMs: number) {
super(
`Sandbox ${namespace}/${name} did not reach Ready phase within ${timeoutMs}ms`,
);
this.name = "SandboxCrTimeoutError";
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Map a Sandbox CR status.phase value to our SandboxStatus shape.
* Sandbox phases: Pending | Ready | Terminating | Failed
*/
function mapSandboxPhase(
cr: Record<string, unknown>,
): SandboxStatus {
const status = (cr.status as Record<string, unknown>) ?? {};
const phase = (status.phase as string) ?? "Pending";
switch (phase) {
case "Ready":
return {
phase: "Running", // SandboxStatus.phase uses Job semantics; "Running" = active pod
complete: false,
active: 1,
succeeded: 0,
failed: 0,
};
case "Terminating":
return {
phase: "Running",
complete: false,
active: 0,
succeeded: 0,
failed: 0,
reason: "Terminating",
};
case "Failed": {
const conditions = (status.conditions as { type?: string; reason?: string; message?: string }[]) ?? [];
const failedCond = conditions.find((c) => c.type === "Failed");
return {
phase: "Failed",
complete: false,
active: 0,
succeeded: 0,
failed: 1,
reason: failedCond?.reason,
message: failedCond?.message,
};
}
default:
// "Pending" or unknown
return {
phase: "Pending",
complete: false,
active: 0,
succeeded: 0,
failed: 0,
};
}
}
export async function createSandboxCr(
clients: KubeClients,
namespace: string,
manifest: Record<string, unknown>,
): Promise<{ uid: string }> {
const result = await clients.custom.createNamespacedCustomObject({
group: SANDBOX_GROUP,
version: SANDBOX_VERSION,
namespace,
plural: SANDBOX_PLURAL,
body: manifest,
});
const uid = (result as { metadata?: { uid?: string } }).metadata?.uid;
if (!uid) throw new Error("Sandbox CR created without a UID");
return { uid };
}
export async function getSandboxCrStatus(
clients: KubeClients,
namespace: string,
name: string,
): Promise<SandboxStatus> {
const result = await clients.custom.getNamespacedCustomObject({
group: SANDBOX_GROUP,
version: SANDBOX_VERSION,
namespace,
plural: SANDBOX_PLURAL,
name,
});
return mapSandboxPhase(result as Record<string, unknown>);
}
/**
* Returns the pod name backing a Sandbox CR.
* Primary: read status.podName from the CR (set by the controller once ready).
* Fallback: list pods in the namespace filtered by the paperclip.io/managed-by
* label and the sandbox name label set on the pod template.
*/
export async function findPodForSandbox(
clients: KubeClients,
namespace: string,
name: string,
): Promise<string | null> {
// Primary: read status.podName from the Sandbox CR
const cr = await clients.custom.getNamespacedCustomObject({
group: SANDBOX_GROUP,
version: SANDBOX_VERSION,
namespace,
plural: SANDBOX_PLURAL,
name,
}) as Record<string, unknown>;
const status = (cr.status as Record<string, unknown>) ?? {};
const podName = status.podName as string | undefined;
if (podName && podName.trim().length > 0) {
return podName;
}
// Fallback: list pods with sandbox-name label (sandbox controller typically
// labels pods with the sandbox name)
const result = await clients.core.listNamespacedPod({
namespace,
labelSelector: `paperclip.io/managed-by=paperclip-k8s-plugin`,
});
const items =
(
(
result as {
items?: {
metadata?: { name?: string; labels?: Record<string, string> };
status?: { phase?: string };
}[];
}
).items
) ?? [];
// Filter to pods that belong to this sandbox by name prefix or label
const matching = items.filter((p) => {
const podMeta = p.metadata ?? {};
const labels = podMeta.labels ?? {};
// The sandbox controller may label pods differently; try matching by name prefix
return (
podMeta.name?.startsWith(name) ||
labels["agents.x-k8s.io/sandbox-name"] === name
);
});
const running = matching.find((p) => p.status?.phase === "Running");
return (running ?? matching[0])?.metadata?.name ?? null;
}
export async function streamSandboxLogs(
clients: KubeClients,
namespace: string,
podName: string,
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
): Promise<void> {
// V1 limitation: the Pod log API returns the container's combined log stream. The
// sleep-infinity pod will have minimal output; this is provided for interface
// conformance. For actual command output, use execInPod() directly.
const result = await clients.core.readNamespacedPodLog({
namespace,
name: podName,
});
const text =
typeof result === "string"
? result
: typeof (result as { body?: unknown })?.body === "string"
? (result as { body: string }).body
: "";
if (text.length > 0) await onChunk("stdout", text);
}
export async function deleteSandboxCr(
clients: KubeClients,
namespace: string,
name: string,
): Promise<void> {
await clients.custom.deleteNamespacedCustomObject({
group: SANDBOX_GROUP,
version: SANDBOX_VERSION,
namespace,
plural: SANDBOX_PLURAL,
name,
propagationPolicy: "Foreground",
});
}
/**
* Wait until the Sandbox CR's pod reaches Ready phase (i.e., the pod is up and
* exec-able). This is NOT waiting for a workload to finish — the Sandbox pod
* runs sleep infinity indefinitely. Execution completion is tracked by the
* individual execInPod() calls.
*
* Throws SandboxCrTimeoutError if Ready is not reached within timeoutMs.
* Throws if the Sandbox transitions to Failed.
*/
export async function waitForSandboxReady(
clients: KubeClients,
namespace: string,
name: string,
opts: { timeoutMs: number; pollMs?: number } = {
timeoutMs: 120_000,
pollMs: 2000,
},
): Promise<SandboxStatus> {
const deadline = Date.now() + opts.timeoutMs;
const pollMs = opts.pollMs ?? 2000;
while (Date.now() < deadline) {
const cr = await clients.custom.getNamespacedCustomObject({
group: SANDBOX_GROUP,
version: SANDBOX_VERSION,
namespace,
plural: SANDBOX_PLURAL,
name,
}) as Record<string, unknown>;
const status = (cr.status as Record<string, unknown>) ?? {};
const phase = (status.phase as string) ?? "Pending";
if (phase === "Ready") {
return mapSandboxPhase(cr);
}
if (phase === "Failed") {
const mapped = mapSandboxPhase(cr);
throw new Error(
`Sandbox ${namespace}/${name} failed: ${mapped.reason ?? "unknown reason"}${mapped.message ?? ""}`,
);
}
if (phase === "Terminating") {
throw new Error(`Sandbox ${namespace}/${name} is terminating before it became ready`);
}
// Pending or unknown — keep polling
await sleep(pollMs);
}
throw new SandboxCrTimeoutError(namespace, name, opts.timeoutMs);
}
/**
* Sandbox CR-backed conformance to SandboxOrchestrator.
*
* waitForCompletion semantics change: for this backend, "completion" means
* "pod is up and Ready to exec into" — NOT "workload finished". The actual
* command execution and its completion is handled by execInPod().
*/
export const sandboxCrOrchestrator: SandboxOrchestrator = {
claim: createSandboxCr,
getStatus: getSandboxCrStatus,
findPod: findPodForSandbox,
streamLogs: streamSandboxLogs,
release: deleteSandboxCr,
waitForCompletion: waitForSandboxReady,
};
@@ -0,0 +1,68 @@
import type { KubeClients } from "./kube-client.js";
export interface SandboxStatus {
phase: "Pending" | "Running" | "Succeeded" | "Failed";
complete: boolean;
active: number;
succeeded: number;
failed: number;
reason?: string;
message?: string;
}
/**
* Abstract interface over a sandbox runtime backend. The current implementation
* is Job-backed (job-orchestrator.ts). Future backends slot in by exporting an
* object conforming to this shape — e.g. a Kata-FC warm-pool backend that
* additionally implements the optional pause/resume slots, or a CRD-backed
* backend on kubernetes-sigs/agent-sandbox once it reaches Beta.
*/
export interface SandboxOrchestrator {
/** Provision the sandbox. Returns the runtime's stable UID. */
claim(
clients: KubeClients,
namespace: string,
manifest: Record<string, unknown>,
): Promise<{ uid: string }>;
/** Read current lifecycle phase. */
getStatus(
clients: KubeClients,
namespace: string,
name: string,
): Promise<SandboxStatus>;
/** Locate the pod backing this sandbox (or null if none exists yet). */
findPod(
clients: KubeClients,
namespace: string,
name: string,
): Promise<string | null>;
/** Read logs from the sandbox's pod. V1: post-completion read. */
streamLogs(
clients: KubeClients,
namespace: string,
podName: string,
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
): Promise<void>;
/** Tear down the sandbox. Implementations MUST cascade-delete child resources. */
release(clients: KubeClients, namespace: string, name: string): Promise<void>;
/** Block until phase is Succeeded or Failed, or throw on timeout. */
waitForCompletion(
clients: KubeClients,
namespace: string,
name: string,
opts: { timeoutMs: number; pollMs?: number },
): Promise<SandboxStatus>;
// Optional warm-pool / Kata-FC extension slots. Job-backed implementation
// does not provide these; runtimes that do (e.g. Kata-FC microVM pause)
// implement them and acquire the warm-pool capability.
// TODO: requires custom in-cluster controller for k8s — kubelet does not
// expose pause/resume at the pod level. Add when warm-pool design lands.
pause?(clients: KubeClients, namespace: string, name: string): Promise<void>;
resume?(clients: KubeClients, namespace: string, name: string): Promise<void>;
}
@@ -0,0 +1,52 @@
import type { KubeClients } from "./kube-client.js";
export interface CreatePerRunSecretInput {
namespace: string;
secretName: string;
runId: string;
ownerKind: string;
ownerApiVersion: string;
ownerName: string;
ownerUid: string;
bootstrapToken: string;
adapterEnv: Record<string, string>;
}
export async function createPerRunSecret(clients: KubeClients, input: CreatePerRunSecretInput): Promise<void> {
if (!input.ownerUid) {
throw new Error("createPerRunSecret requires a non-empty ownerUid");
}
if ("BOOTSTRAP_TOKEN" in input.adapterEnv) {
throw new Error("adapterEnv must not contain BOOTSTRAP_TOKEN (reserved key)");
}
await clients.core.createNamespacedSecret({
namespace: input.namespace,
body: {
apiVersion: "v1",
kind: "Secret",
type: "Opaque",
metadata: {
name: input.secretName,
namespace: input.namespace,
labels: {
"paperclip.io/run-id": input.runId,
"paperclip.io/managed-by": "paperclip-k8s-plugin",
},
ownerReferences: [
{
apiVersion: input.ownerApiVersion,
kind: input.ownerKind,
name: input.ownerName,
uid: input.ownerUid,
controller: true,
blockOwnerDeletion: true,
},
],
},
stringData: {
BOOTSTRAP_TOKEN: input.bootstrapToken,
...input.adapterEnv,
},
},
});
}
@@ -0,0 +1,3 @@
export function shellQuoteArg(arg: string): string {
return "'" + arg.replace(/'/g, "'\\''") + "'";
}
@@ -0,0 +1,425 @@
import type { KubeClients } from "./kube-client.js";
import { buildNetworkPolicyManifests } from "./network-policy.js";
import { buildCiliumNetworkPolicyManifest } from "./cilium-network-policy.js";
export interface EnsureTenantInput {
namespace: string;
companyId: string;
paperclipServerNamespace: string;
serviceAccountAnnotations: Record<string, string>;
egressMode: "standard" | "cilium";
egressAllowFqdns: string[];
egressAllowCidrs: string[];
resourceQuota: {
pods: string;
requestsCpu: string;
requestsMemory: string;
limitsCpu: string;
limitsMemory: string;
};
}
const SERVICE_ACCOUNT_NAME = "paperclip-tenant-sa";
const ROLE_NAME = "paperclip-tenant-role";
const ROLE_BINDING_NAME = "paperclip-tenant-rb";
const RESOURCE_QUOTA_NAME = "paperclip-quota";
const LIMIT_RANGE_NAME = "paperclip-limits";
/**
* Tenant provisioning reconciles the resources this plugin owns. Existing
* resources are replaced with the desired manifest so quota, RBAC, service
* account annotations, and egress policy changes take effect on the next run.
*/
export async function ensureTenant(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
await ensureNamespace(clients, input);
await ensureServiceAccount(clients, input);
await ensureRole(clients, input);
await ensureRoleBinding(clients, input);
await ensureResourceQuota(clients, input);
await ensureLimitRange(clients, input);
await ensureNetworkPolicies(clients, input);
}
async function ensureNamespace(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
const manifest = buildNamespaceManifest(input);
try {
const existing = await clients.core.readNamespace({ name: input.namespace });
await clients.core.replaceNamespace({
name: input.namespace,
body: withResourceVersion(buildNamespaceManifest(input, existing), existing) as never,
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.core.createNamespace({ body: manifest });
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.core.readNamespace({ name: input.namespace });
await clients.core.replaceNamespace({
name: input.namespace,
body: withResourceVersion(buildNamespaceManifest(input, existing), existing) as never,
});
}
}
function buildNamespaceManifest(input: EnsureTenantInput, existing?: unknown): Record<string, unknown> {
const existingLabels = (existing as { metadata?: { labels?: Record<string, string> } })?.metadata?.labels ?? {};
return {
apiVersion: "v1",
kind: "Namespace",
metadata: {
name: input.namespace,
labels: {
...existingLabels,
"paperclip.io/company-id": input.companyId,
"paperclip.io/managed-by": "paperclip-k8s-plugin",
"pod-security.kubernetes.io/enforce": "restricted",
"pod-security.kubernetes.io/audit": "restricted",
"pod-security.kubernetes.io/warn": "restricted",
},
},
};
}
async function ensureServiceAccount(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
const manifest = {
apiVersion: "v1",
kind: "ServiceAccount",
metadata: {
name: SERVICE_ACCOUNT_NAME,
namespace: input.namespace,
annotations: input.serviceAccountAnnotations,
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
},
};
try {
const existing = await clients.core.readNamespacedServiceAccount({ name: SERVICE_ACCOUNT_NAME, namespace: input.namespace });
await clients.core.replaceNamespacedServiceAccount({
name: SERVICE_ACCOUNT_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.core.createNamespacedServiceAccount({ namespace: input.namespace, body: manifest });
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.core.readNamespacedServiceAccount({ name: SERVICE_ACCOUNT_NAME, namespace: input.namespace });
await clients.core.replaceNamespacedServiceAccount({
name: SERVICE_ACCOUNT_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
}
}
async function ensureRole(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
const manifest = {
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: { name: ROLE_NAME, namespace: input.namespace },
rules: [
{ apiGroups: [""], resources: ["pods/log"], verbs: ["get"] },
],
};
try {
const existing = await clients.rbac.readNamespacedRole({ name: ROLE_NAME, namespace: input.namespace });
await clients.rbac.replaceNamespacedRole({
name: ROLE_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.rbac.createNamespacedRole({ namespace: input.namespace, body: manifest });
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.rbac.readNamespacedRole({ name: ROLE_NAME, namespace: input.namespace });
await clients.rbac.replaceNamespacedRole({
name: ROLE_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
}
}
async function ensureRoleBinding(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
const manifest = {
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: ROLE_BINDING_NAME, namespace: input.namespace },
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: ROLE_NAME },
subjects: [{ kind: "ServiceAccount", name: SERVICE_ACCOUNT_NAME, namespace: input.namespace }],
};
try {
const existing = await clients.rbac.readNamespacedRoleBinding({ name: ROLE_BINDING_NAME, namespace: input.namespace });
await clients.rbac.replaceNamespacedRoleBinding({
name: ROLE_BINDING_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.rbac.createNamespacedRoleBinding({ namespace: input.namespace, body: manifest });
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.rbac.readNamespacedRoleBinding({ name: ROLE_BINDING_NAME, namespace: input.namespace });
await clients.rbac.replaceNamespacedRoleBinding({
name: ROLE_BINDING_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
}
}
async function ensureResourceQuota(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
const manifest = {
apiVersion: "v1",
kind: "ResourceQuota",
metadata: { name: RESOURCE_QUOTA_NAME, namespace: input.namespace },
spec: {
hard: {
pods: input.resourceQuota.pods,
"requests.cpu": input.resourceQuota.requestsCpu,
"requests.memory": input.resourceQuota.requestsMemory,
"limits.cpu": input.resourceQuota.limitsCpu,
"limits.memory": input.resourceQuota.limitsMemory,
},
},
};
try {
const existing = await clients.core.readNamespacedResourceQuota({ name: RESOURCE_QUOTA_NAME, namespace: input.namespace });
await clients.core.replaceNamespacedResourceQuota({
name: RESOURCE_QUOTA_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.core.createNamespacedResourceQuota({ namespace: input.namespace, body: manifest });
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.core.readNamespacedResourceQuota({ name: RESOURCE_QUOTA_NAME, namespace: input.namespace });
await clients.core.replaceNamespacedResourceQuota({
name: RESOURCE_QUOTA_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
}
}
async function ensureLimitRange(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
const manifest = {
apiVersion: "v1",
kind: "LimitRange",
metadata: { name: LIMIT_RANGE_NAME, namespace: input.namespace },
spec: {
limits: [
{
type: "Container",
max: { cpu: "4", memory: "8Gi" },
min: { cpu: "100m", memory: "128Mi" },
// The k8s client-node type names this `_default` but the actual
// Kubernetes API field is `default`. We produce a JSON-shape
// manifest so the cast is safe.
default: { cpu: "1", memory: "2Gi" },
defaultRequest: { cpu: "250m", memory: "512Mi" },
},
],
},
};
try {
const existing = await clients.core.readNamespacedLimitRange({ name: LIMIT_RANGE_NAME, namespace: input.namespace });
await clients.core.replaceNamespacedLimitRange({
name: LIMIT_RANGE_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.core.createNamespacedLimitRange({
namespace: input.namespace,
body: manifest as never,
});
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.core.readNamespacedLimitRange({ name: LIMIT_RANGE_NAME, namespace: input.namespace });
await clients.core.replaceNamespacedLimitRange({
name: LIMIT_RANGE_NAME,
namespace: input.namespace,
body: withResourceVersion(manifest, existing) as never,
});
}
}
async function ensureNetworkPolicies(clients: KubeClients, input: EnsureTenantInput): Promise<void> {
const [denyAll, egressStd] = buildNetworkPolicyManifests({
namespace: input.namespace,
paperclipServerNamespace: input.paperclipServerNamespace,
egressAllowFqdns: input.egressAllowFqdns,
egressAllowCidrs: input.egressAllowCidrs,
});
await ensureNetworkPolicy(clients, input.namespace, denyAll);
if (input.egressMode === "cilium") {
const cnp = buildCiliumNetworkPolicyManifest({
namespace: input.namespace,
paperclipServerNamespace: input.paperclipServerNamespace,
egressAllowFqdns: input.egressAllowFqdns,
egressAllowCidrs: input.egressAllowCidrs,
});
await ensureCiliumNetworkPolicy(clients, input.namespace, cnp);
await deleteNetworkPolicyIfExists(clients, input.namespace, "paperclip-egress-allow");
} else {
await ensureNetworkPolicy(clients, input.namespace, egressStd);
await deleteCiliumNetworkPolicyIfExists(clients, input.namespace, "paperclip-egress-fqdn");
}
}
async function ensureNetworkPolicy(
clients: KubeClients,
namespace: string,
manifest: Record<string, unknown>,
): Promise<void> {
const name = (manifest.metadata as { name: string }).name;
try {
const existing = await clients.networking.readNamespacedNetworkPolicy({ name, namespace });
await clients.networking.replaceNamespacedNetworkPolicy({
name,
namespace,
body: withResourceVersion(manifest, existing) as never,
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.networking.createNamespacedNetworkPolicy({ namespace, body: manifest as never });
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.networking.readNamespacedNetworkPolicy({ name, namespace });
await clients.networking.replaceNamespacedNetworkPolicy({
name,
namespace,
body: withResourceVersion(manifest, existing) as never,
});
}
}
async function ensureCiliumNetworkPolicy(
clients: KubeClients,
namespace: string,
manifest: Record<string, unknown>,
): Promise<void> {
const name = (manifest.metadata as { name: string }).name;
try {
const existing = await clients.custom.getNamespacedCustomObject({
group: "cilium.io",
version: "v2",
namespace,
plural: "ciliumnetworkpolicies",
name,
});
await clients.custom.replaceNamespacedCustomObject({
group: "cilium.io",
version: "v2",
namespace,
plural: "ciliumnetworkpolicies",
name,
body: withResourceVersion(manifest, existing),
});
return;
} catch (err) {
if (!isNotFound(err)) throw err;
}
try {
await clients.custom.createNamespacedCustomObject({
group: "cilium.io",
version: "v2",
namespace,
plural: "ciliumnetworkpolicies",
body: manifest,
});
} catch (err) {
if (!isAlreadyExists(err)) throw err;
const existing = await clients.custom.getNamespacedCustomObject({
group: "cilium.io",
version: "v2",
namespace,
plural: "ciliumnetworkpolicies",
name,
});
await clients.custom.replaceNamespacedCustomObject({
group: "cilium.io",
version: "v2",
namespace,
plural: "ciliumnetworkpolicies",
name,
body: withResourceVersion(manifest, existing),
});
}
}
async function deleteNetworkPolicyIfExists(clients: KubeClients, namespace: string, name: string): Promise<void> {
try {
await clients.networking.deleteNamespacedNetworkPolicy({ name, namespace });
} catch (err) {
if (!isNotFound(err)) throw err;
}
}
async function deleteCiliumNetworkPolicyIfExists(clients: KubeClients, namespace: string, name: string): Promise<void> {
try {
await clients.custom.deleteNamespacedCustomObject({
group: "cilium.io",
version: "v2",
namespace,
plural: "ciliumnetworkpolicies",
name,
});
} catch (err) {
if (!isNotFound(err)) throw err;
}
}
function withResourceVersion<T extends Record<string, unknown>>(manifest: T, existing: unknown): T {
const resourceVersion = (existing as { metadata?: { resourceVersion?: string } })?.metadata?.resourceVersion;
if (!resourceVersion) return manifest;
return {
...manifest,
metadata: {
...(manifest.metadata as Record<string, unknown>),
resourceVersion,
},
};
}
function isNotFound(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as { code?: number; statusCode?: number };
return e.code === 404 || e.statusCode === 404;
}
function isAlreadyExists(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as { code?: number; statusCode?: number };
return e.code === 409 || e.statusCode === 409;
}
@@ -0,0 +1,111 @@
import { z } from "zod";
import { KNOWN_ADAPTER_TYPES } from "./adapter-defaults.js";
function isIpv4Cidr(value: string): boolean {
const [address, prefix, extra] = value.split("/");
if (!address || !prefix || extra !== undefined || !/^\d+$/.test(prefix)) {
return false;
}
const prefixNumber = Number(prefix);
if (prefixNumber < 0 || prefixNumber > 32) {
return false;
}
const octets = address.split(".");
return octets.length === 4 && octets.every((octet) => {
if (!/^\d+$/.test(octet)) {
return false;
}
const value = Number(octet);
return value >= 0 && value <= 255;
});
}
export const kubernetesProviderConfigSchema = z
.object({
inCluster: z.boolean().default(false),
kubeconfig: z.string().optional(),
namespacePrefix: z.string().regex(/^[a-z0-9-]{1,20}$/).default("paperclip-"),
paperclipServerNamespace: z
.string()
.min(1)
.max(63)
.regex(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/)
.default("paperclip"),
companySlug: z.string().regex(/^[a-z0-9-]{1,43}$/).optional(),
imageRegistry: z.string().url().optional(),
imageAllowList: z.array(z.string()).default([]),
imagePullSecrets: z.array(z.string()).default([]),
egressAllowFqdns: z.array(z.string()).default([]),
egressAllowCidrs: z.array(z.string().refine(isIpv4Cidr, "Invalid CIDR")).default([]),
egressMode: z.enum(["cilium", "standard"]).default("standard"),
defaultResources: z
.object({
requests: z.object({ cpu: z.string(), memory: z.string() }).partial().optional(),
limits: z.object({ cpu: z.string(), memory: z.string() }).partial().optional(),
})
.optional(),
runtimeClassName: z.string().optional(),
serviceAccountAnnotations: z.record(z.string()).default({}),
jobTtlSecondsAfterFinished: z.number().int().nonnegative().default(900),
podActivityDeadlineSec: z.number().int().positive().default(3600),
/**
* The adapter type that Jobs in this environment will run.
* Each Kubernetes environment is bound to one adapter; create multiple
* environments for different adapters.
* Defaults to `"claude_local"`.
*/
adapterType: z
.string()
.default("claude_local")
.refine((v) => KNOWN_ADAPTER_TYPES.has(v), {
message: "adapterType must be one of the known adapter types",
}),
/**
* The sandbox backend to use.
*
* - `"sandbox-cr"` (default, alpha) — uses the kubernetes-sigs/agent-sandbox
* Sandbox CRD (agents.x-k8s.io/v1alpha1). Creates a long-lived pod that
* paperclip-server can exec into for multi-command adapter-install workflows.
* Requires the agent-sandbox controller to be installed in the cluster.
*
* - `"job"` — uses batch/v1 Job (stable fallback). One-shot entrypoint; does
* NOT support multi-command exec. Use this for clusters without agent-sandbox
* installed, or when you need stable (non-alpha) k8s APIs.
*/
backend: z.enum(["sandbox-cr", "job"]).default("sandbox-cr"),
})
.refine(
(cfg) => cfg.inCluster || (typeof cfg.kubeconfig === "string" && cfg.kubeconfig.trim().length > 0),
{
message:
"kubernetes provider requires one of `inCluster` or `kubeconfig`",
},
);
export type KubernetesProviderConfig = z.infer<typeof kubernetesProviderConfigSchema>;
export function parseKubernetesProviderConfig(input: unknown): KubernetesProviderConfig {
return kubernetesProviderConfigSchema.parse(input);
}
export interface KubernetesLeaseMetadata {
namespace: string;
/** Name of the workload resource (Job name for job backend, Sandbox CR name for sandbox-cr backend). */
jobName: string;
podName: string | null;
secretName: string;
phase: "Pending" | "Running" | "Succeeded" | "Failed";
/** Which backend provisioned this lease. */
backend: "sandbox-cr" | "job";
}
@@ -0,0 +1,154 @@
/**
* Fast-upload interceptor for the chunked-shell file transfer protocol used by
* `@paperclipai/adapter-utils` command-managed runtimes.
*
* The normal path writes files through many shell execs:
* 1. mkdir/rm/touch `<target>.paperclip-upload.b64`
* 2. append many base64 chunks with printf
* 3. base64-decode the temp file into the final target
*
* On Kubernetes each exec is a new WebSocket round trip. This state machine
* recognizes that exact protocol, buffers the base64 chunks in the plugin
* worker, and lets the caller flush the final payload through one exec.
* Pattern drift or missing state falls through to the original exec path.
*/
import { posix as pathPosix } from "node:path";
const INIT_RE =
/^mkdir -p '([^']+)' && rm -f '([^']+)\.paperclip-upload\.b64' && : > '\2\.paperclip-upload\.b64'$/;
const CHUNK_RE =
/^printf '%s' '([A-Za-z0-9+/]+={0,2})' >> '([^']+)\.paperclip-upload\.b64'$/;
const FINALIZE_RE =
/^base64 -d < '([^']+)\.paperclip-upload\.b64' > '\1' && rm -f '\1\.paperclip-upload\.b64'$/;
const MAX_BUFFER_BYTES = 100 * 1024 * 1024;
export interface FastUploadFlush {
targetPath: string;
payload: Buffer;
}
export type FastUploadDecision =
| { action: "ack"; reason: string }
| { action: "flush"; flush: FastUploadFlush }
| { action: "error"; message: string }
| { action: "passthrough"; reason: string };
interface BufferedUpload {
targetPath: string;
chunks: string[];
totalBase64Chars: number;
sawPaddedChunk: boolean;
}
export class FastUploadInterceptor {
private readonly buffers = new Map<string, BufferedUpload>();
constructor(private readonly maxBufferBytes = MAX_BUFFER_BYTES) {}
decide(command: string): FastUploadDecision {
const initMatch = INIT_RE.exec(command);
if (initMatch) {
const dir = initMatch[1];
const targetPath = initMatch[2];
if (pathPosix.dirname(targetPath) !== dir) {
return { action: "passthrough", reason: "init dir/target mismatch" };
}
const tempPath = `${targetPath}.paperclip-upload.b64`;
if (this.buffers.has(tempPath)) {
this.buffers.delete(tempPath);
return {
action: "error",
message: `Fast upload already in progress for ${targetPath}; retry the upload from the beginning.`,
};
}
this.buffers.set(tempPath, {
targetPath,
chunks: [],
totalBase64Chars: 0,
sawPaddedChunk: false,
});
return { action: "ack", reason: `init upload to ${targetPath}` };
}
const chunkMatch = CHUNK_RE.exec(command);
if (chunkMatch) {
const base64Chunk = chunkMatch[1];
const targetPath = chunkMatch[2];
const tempPath = `${targetPath}.paperclip-upload.b64`;
const upload = this.buffers.get(tempPath);
if (!upload) {
return { action: "passthrough", reason: "chunk without prior init" };
}
if (upload.sawPaddedChunk) {
this.buffers.delete(tempPath);
return {
action: "error",
message: `Fast upload received data after a padded chunk for ${upload.targetPath}; retry the upload from the beginning.`,
};
}
if (upload.totalBase64Chars + base64Chunk.length > (this.maxBufferBytes * 4) / 3) {
this.buffers.delete(tempPath);
return {
action: "error",
message: `Fast upload buffer cap exceeded for ${upload.targetPath}; retry the upload with a smaller payload.`,
};
}
upload.chunks.push(base64Chunk);
upload.totalBase64Chars += base64Chunk.length;
upload.sawPaddedChunk = base64Chunk.endsWith("=");
return { action: "ack", reason: `buffered ${base64Chunk.length} base64 chars` };
}
const finalizeMatch = FINALIZE_RE.exec(command);
if (finalizeMatch) {
const targetPath = finalizeMatch[1];
const tempPath = `${targetPath}.paperclip-upload.b64`;
const upload = this.buffers.get(tempPath);
if (!upload) {
return { action: "passthrough", reason: "finalize without buffered state" };
}
this.buffers.delete(tempPath);
return {
action: "flush",
flush: {
targetPath: upload.targetPath,
payload: Buffer.from(upload.chunks.join(""), "base64"),
},
};
}
const activeUpload = this.findActiveUploadForCommand(command);
if (activeUpload) {
this.buffers.delete(activeUpload.tempPath);
return {
action: "error",
message: `Fast upload protocol violation for ${activeUpload.upload.targetPath}; retry the upload from the beginning.`,
};
}
return { action: "passthrough", reason: "no upload pattern" };
}
reset(): void {
this.buffers.clear();
}
get pendingCount(): number {
return this.buffers.size;
}
private findActiveUploadForCommand(command: string): { tempPath: string; upload: BufferedUpload } | null {
for (const [tempPath, upload] of this.buffers) {
if (command.includes(`'${tempPath}'`)) {
return { tempPath, upload };
}
}
return null;
}
}
@@ -0,0 +1,49 @@
import { randomBytes } from "node:crypto";
const ULID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
export function deriveCompanySlug(input: string): string {
const slug = input
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32)
.replace(/-+$/, "");
return slug.length > 0 ? slug : "company";
}
export function deriveNamespaceName(prefix: string, slug: string): string {
return `${prefix}${slug}`;
}
export function newRunUlidDns(now: () => number = Date.now): string {
const timestamp = now();
let out = "";
let t = timestamp;
for (let i = 0; i < 10; i++) {
out = ULID_ALPHABET[t & 0x1f] + out;
t = Math.floor(t / 32);
}
const randBytes = randomBytes(16);
for (let i = 0; i < 16; i++) {
out += ULID_ALPHABET[randBytes[i] & 0x1f];
}
return out;
}
export interface LabelsInput {
runId: string;
agentId: string;
companyId: string;
adapterType: string;
}
export function paperclipLabels(input: LabelsInput): Record<string, string> {
return {
"paperclip.io/run-id": input.runId,
"paperclip.io/agent-id": input.agentId,
"paperclip.io/company-id": input.companyId,
"paperclip.io/adapter": input.adapterType,
"paperclip.io/managed-by": "paperclip-k8s-plugin",
};
}
@@ -0,0 +1,5 @@
import { runWorker } from "@paperclipai/plugin-sdk";
import plugin from "./plugin.js";
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,22 @@
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export const KIND_CONTEXT = "kind-paperclip";
export function readKindKubeconfig(): string {
return readFileSync(join(homedir(), ".kube", "config"), "utf-8");
}
export function kubectl(args: string): string {
return execSync(`kubectl --context ${KIND_CONTEXT} ${args}`, { encoding: "utf-8" });
}
export function deleteNamespaceIfExists(namespace: string): void {
try {
kubectl(`delete namespace ${namespace} --wait=true --timeout=60s --ignore-not-found`);
} catch {
// ignore
}
}
@@ -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();
});
});
@@ -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",
},
});
+15
View File
@@ -476,6 +476,13 @@ export interface PluginEnvironmentLease {
export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams { export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDriverBaseParams {
runId: string; 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; workspaceMode?: string;
requestedCwd?: string; requestedCwd?: string;
} }
@@ -483,6 +490,14 @@ export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDr
export interface PluginEnvironmentResumeLeaseParams extends PluginEnvironmentDriverBaseParams { export interface PluginEnvironmentResumeLeaseParams extends PluginEnvironmentDriverBaseParams {
providerLeaseId: string; providerLeaseId: string;
leaseMetadata?: Record<string, unknown>; 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 { export interface PluginEnvironmentReleaseLeaseParams extends PluginEnvironmentDriverBaseParams {
+16
View File
@@ -281,6 +281,22 @@ export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumen
export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const; export const ISSUE_REFERENCE_SOURCE_KINDS = ["title", "description", "comment", "document"] as const;
export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number]; export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[number];
export const DOCUMENT_ANNOTATION_THREAD_STATUSES = ["open", "resolved"] as const;
export type DocumentAnnotationThreadStatus = (typeof DOCUMENT_ANNOTATION_THREAD_STATUSES)[number];
export const DOCUMENT_ANNOTATION_ANCHOR_STATES = ["active", "stale", "orphaned"] as const;
export type DocumentAnnotationAnchorState = (typeof DOCUMENT_ANNOTATION_ANCHOR_STATES)[number];
export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [
"exact",
"duplicate",
"fuzzy",
"ambiguous",
"missing",
] as const;
export type DocumentAnnotationAnchorConfidence =
(typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number];
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const; export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number]; export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
+464
View File
@@ -0,0 +1,464 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorState,
} from "./constants.js";
import type {
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorSnapshot,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,
} from "./types/document-annotation.js";
export interface CreateDocumentAnchorSelectorOptions {
contextLength?: number;
}
export interface VerifyDocumentAnchorSelectorInput {
markdown: string;
selector: DocumentAnnotationAnchorSelector;
contextLength?: number;
}
export interface VerifyDocumentAnchorSelectorResult {
ok: boolean;
anchor: DocumentAnnotationAnchorSnapshot | null;
projection: DocumentTextProjection;
reason: "verified" | "quote_mismatch" | "position_mismatch" | "invalid_range";
}
export interface RemapDocumentAnchorInput {
previousAnchor: DocumentAnnotationAnchorSnapshot;
nextMarkdown: string;
contextLength?: number;
}
export interface RemapDocumentAnchorResult {
anchorState: DocumentAnnotationAnchorState;
confidence: DocumentAnnotationAnchorConfidence;
anchor: DocumentAnnotationAnchorSnapshot | null;
projection: DocumentTextProjection;
reason: "exact" | "duplicate" | "fuzzy" | "ambiguous" | "missing";
}
interface Candidate {
start: number;
end: number;
score: number;
reason: RemapDocumentAnchorResult["reason"];
}
const DEFAULT_CONTEXT_LENGTH = 48;
export function normalizeAnchorText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
export function projectMarkdownToText(markdown: string): DocumentTextProjection {
const builder = new ProjectionBuilder(markdown);
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? [markdown];
let offset = 0;
let inFence = false;
for (const rawLine of lines) {
if (rawLine === "") continue;
const hasNewline = rawLine.endsWith("\n");
const line = hasNewline ? rawLine.slice(0, -1) : rawLine;
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
if (fenceMatch) {
inFence = !inFence;
offset += rawLine.length;
builder.addSeparator(offset - (hasNewline ? 1 : 0));
continue;
}
if (inFence) {
builder.addText(line, offset);
builder.addSeparator(offset + line.length);
offset += rawLine.length;
continue;
}
const { text, sourceOffset } = stripBlockSyntax(line, offset);
addInlineMarkdownText(builder, text, sourceOffset);
builder.addSeparator(offset + line.length);
offset += rawLine.length;
}
return builder.toProjection();
}
export function resolveProjectionRange(
projection: DocumentTextProjection,
normalizedStart: number,
normalizedEnd: number,
): DocumentTextRange | null {
if (
normalizedStart < 0
|| normalizedEnd <= normalizedStart
|| normalizedEnd > projection.text.length
|| normalizedStart >= projection.positions.length
|| normalizedEnd - 1 >= projection.positions.length
) {
return null;
}
return {
text: projection.text.slice(normalizedStart, normalizedEnd),
normalizedStart,
normalizedEnd,
markdownStart: projection.positions[normalizedStart]?.sourceStart ?? 0,
markdownEnd: projection.positions[normalizedEnd - 1]?.sourceEnd ?? 0,
};
}
export function createDocumentAnchorSelector(
projection: DocumentTextProjection,
range: DocumentTextRange,
options: CreateDocumentAnchorSelectorOptions = {},
): DocumentAnnotationAnchorSelector {
const contextLength = options.contextLength ?? DEFAULT_CONTEXT_LENGTH;
return {
quote: {
exact: range.text,
prefix: projection.text.slice(Math.max(0, range.normalizedStart - contextLength), range.normalizedStart),
suffix: projection.text.slice(range.normalizedEnd, range.normalizedEnd + contextLength),
},
position: {
normalizedStart: range.normalizedStart,
normalizedEnd: range.normalizedEnd,
markdownStart: range.markdownStart,
markdownEnd: range.markdownEnd,
},
};
}
export function selectorToAnchorSnapshot(selector: DocumentAnnotationAnchorSelector): DocumentAnnotationAnchorSnapshot {
return {
selectedText: selector.quote.exact,
prefixText: selector.quote.prefix,
suffixText: selector.quote.suffix,
normalizedStart: selector.position.normalizedStart,
normalizedEnd: selector.position.normalizedEnd,
markdownStart: selector.position.markdownStart,
markdownEnd: selector.position.markdownEnd,
};
}
export function anchorSnapshotToSelector(anchor: DocumentAnnotationAnchorSnapshot): DocumentAnnotationAnchorSelector {
return {
quote: {
exact: anchor.selectedText,
prefix: anchor.prefixText,
suffix: anchor.suffixText,
},
position: {
normalizedStart: anchor.normalizedStart,
normalizedEnd: anchor.normalizedEnd,
markdownStart: anchor.markdownStart,
markdownEnd: anchor.markdownEnd,
},
};
}
export function verifyDocumentAnchorSelector(
input: VerifyDocumentAnchorSelectorInput,
): VerifyDocumentAnchorSelectorResult {
const projection = projectMarkdownToText(input.markdown);
const range = resolveProjectionRange(
projection,
input.selector.position.normalizedStart,
input.selector.position.normalizedEnd,
);
if (!range) {
return { ok: false, anchor: null, projection, reason: "invalid_range" };
}
if (normalizeAnchorText(range.text) !== normalizeAnchorText(input.selector.quote.exact)) {
return { ok: false, anchor: null, projection, reason: "quote_mismatch" };
}
if (
range.markdownStart !== input.selector.position.markdownStart
|| range.markdownEnd !== input.selector.position.markdownEnd
) {
return { ok: false, anchor: null, projection, reason: "position_mismatch" };
}
const selector = createDocumentAnchorSelector(projection, range, {
contextLength: input.contextLength ?? DEFAULT_CONTEXT_LENGTH,
});
return { ok: true, anchor: selectorToAnchorSnapshot(selector), projection, reason: "verified" };
}
export function remapDocumentAnchor(input: RemapDocumentAnchorInput): RemapDocumentAnchorResult {
const projection = projectMarkdownToText(input.nextMarkdown);
const contextLength = input.contextLength ?? DEFAULT_CONTEXT_LENGTH;
const quote = normalizeAnchorText(input.previousAnchor.selectedText);
if (!quote) {
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
}
const exactCandidates = findOccurrences(projection.text, quote).map((start) => scoreCandidate({
projection,
start,
end: start + quote.length,
previousAnchor: input.previousAnchor,
reason: "exact",
contextLength,
}));
if (exactCandidates.length > 0) {
exactCandidates.sort((a, b) => b.score - a.score);
const [best, second] = exactCandidates;
if (exactCandidates.length > 1 && (!second || Math.abs(best.score - second.score) < 0.05)) {
return {
anchorState: "stale",
confidence: "ambiguous",
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
projection,
reason: "ambiguous",
};
}
return {
anchorState: "active",
confidence: exactCandidates.length === 1 ? "exact" : "duplicate",
anchor: buildAnchorSnapshot(projection, best.start, best.end, contextLength),
projection,
reason: exactCandidates.length === 1 ? "exact" : "duplicate",
};
}
const fuzzy = findFuzzyCandidate(projection, input.previousAnchor, contextLength);
if (fuzzy && fuzzy.score >= 0.58) {
return {
anchorState: "stale",
confidence: "fuzzy",
anchor: buildAnchorSnapshot(projection, fuzzy.start, fuzzy.end, contextLength),
projection,
reason: "fuzzy",
};
}
return { anchorState: "orphaned", confidence: "missing", anchor: null, projection, reason: "missing" };
}
function stripBlockSyntax(line: string, absoluteOffset: number): { text: string; sourceOffset: number } {
const blockMatch = line.match(/^\s{0,3}(?:(#{1,6})\s+|(?:[-+*]|\d+[.)])\s+|>\s?)/);
if (!blockMatch) return { text: line, sourceOffset: absoluteOffset };
return { text: line.slice(blockMatch[0].length), sourceOffset: absoluteOffset + blockMatch[0].length };
}
function addInlineMarkdownText(builder: ProjectionBuilder, text: string, sourceOffset: number): void {
for (let index = 0; index < text.length; index += 1) {
const char = text[index] ?? "";
const absolute = sourceOffset + index;
const rest = text.slice(index);
const image = rest.match(/^!\[([^\]]*)\]\(([^)]*)\)/);
if (image) {
const altStart = absolute + 2;
builder.addText(image[1] ?? "", altStart);
index += image[0].length - 1;
continue;
}
const link = rest.match(/^\[([^\]]+)\]\(([^)]*)\)/);
if (link) {
const labelStart = absolute + 1;
builder.addText(link[1] ?? "", labelStart);
index += link[0].length - 1;
continue;
}
if (char === "`") {
const closing = text.indexOf("`", index + 1);
if (closing > index + 1) {
builder.addText(text.slice(index + 1, closing), absolute + 1);
index = closing;
continue;
}
}
if (char === "|" || char === "\t") {
builder.addSeparator(absolute);
continue;
}
if (isMarkdownFormattingChar(char, text, index)) continue;
builder.addChar(char, absolute, absolute + 1);
}
}
function isMarkdownFormattingChar(char: string, text: string, index: number): boolean {
if (char === "*" || char === "_" || char === "~") return true;
if (char === "\\" && index + 1 < text.length) return true;
return false;
}
function findOccurrences(text: string, quote: string): number[] {
const starts: number[] = [];
let start = text.indexOf(quote);
while (start !== -1) {
starts.push(start);
start = text.indexOf(quote, start + 1);
}
return starts;
}
function scoreCandidate(args: {
projection: DocumentTextProjection;
start: number;
end: number;
previousAnchor: DocumentAnnotationAnchorSnapshot;
reason: Candidate["reason"];
contextLength: number;
}): Candidate {
const before = args.projection.text.slice(Math.max(0, args.start - args.contextLength), args.start);
const after = args.projection.text.slice(args.end, args.end + args.contextLength);
const prefixScore = suffixOverlapScore(args.previousAnchor.prefixText, before);
const suffixScore = prefixOverlapScore(args.previousAnchor.suffixText, after);
const distance = Math.abs(args.start - args.previousAnchor.normalizedStart);
const proximity = 1 / (1 + distance / 200);
return {
start: args.start,
end: args.end,
score: prefixScore * 0.35 + suffixScore * 0.35 + proximity * 0.3,
reason: args.reason,
};
}
function findFuzzyCandidate(
projection: DocumentTextProjection,
previousAnchor: DocumentAnnotationAnchorSnapshot,
contextLength: number,
): Candidate | null {
const words = normalizeAnchorText(previousAnchor.selectedText).split(" ").filter(Boolean);
if (words.length === 0) return null;
const textWords = [...projection.text.matchAll(/\S+/g)].map((match) => ({
text: match[0],
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
}));
const windowSizes = new Set([words.length - 1, words.length, words.length + 1, words.length + 2].filter((n) => n > 0));
let best: Candidate | null = null;
for (const size of windowSizes) {
for (let index = 0; index + size <= textWords.length; index += 1) {
const window = textWords.slice(index, index + size);
const candidateText = window.map((word) => word.text).join(" ");
const similarity = similarityScore(normalizeAnchorText(previousAnchor.selectedText), candidateText);
if (similarity < 0.45) continue;
const scored = scoreCandidate({
projection,
start: window[0]?.start ?? 0,
end: window[window.length - 1]?.end ?? 0,
previousAnchor,
reason: "fuzzy",
contextLength,
});
scored.score = scored.score * 0.35 + similarity * 0.65;
if (!best || scored.score > best.score) best = scored;
}
}
return best;
}
function buildAnchorSnapshot(
projection: DocumentTextProjection,
normalizedStart: number,
normalizedEnd: number,
contextLength: number,
): DocumentAnnotationAnchorSnapshot {
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
if (!range) {
return {
selectedText: "",
prefixText: "",
suffixText: "",
normalizedStart,
normalizedEnd,
markdownStart: 0,
markdownEnd: 0,
};
}
const selector = createDocumentAnchorSelector(projection, range, { contextLength });
return selectorToAnchorSnapshot(selector);
}
function prefixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
const expected = normalizeAnchorText(expectedPrefix);
const actual = normalizeAnchorText(actualPrefix);
if (!expected) return 0.5;
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
if (expected.slice(0, size) === actual.slice(0, size)) return size / expected.length;
}
return 0;
}
function suffixOverlapScore(expectedPrefix: string, actualPrefix: string): number {
const expected = normalizeAnchorText(expectedPrefix);
const actual = normalizeAnchorText(actualPrefix);
if (!expected) return 0.5;
for (let size = Math.min(expected.length, actual.length); size > 0; size -= 1) {
if (expected.slice(-size) === actual.slice(-size)) return size / expected.length;
}
return 0;
}
function similarityScore(left: string, right: string): number {
if (left === right) return 1;
const leftWords = new Set(left.toLowerCase().split(/\s+/).filter(Boolean));
const rightWords = new Set(right.toLowerCase().split(/\s+/).filter(Boolean));
const intersection = [...leftWords].filter((word) => rightWords.has(word)).length;
const union = new Set([...leftWords, ...rightWords]).size || 1;
const jaccard = intersection / union;
const lengthRatio = Math.min(left.length, right.length) / Math.max(left.length, right.length, 1);
return jaccard * 0.75 + lengthRatio * 0.25;
}
class ProjectionBuilder {
private text = "";
private positions: DocumentTextPosition[] = [];
private pendingSpace: DocumentTextPosition | null = null;
constructor(private readonly source: string) {}
addText(text: string, sourceOffset: number): void {
for (let index = 0; index < text.length; index += 1) {
this.addChar(text[index] ?? "", sourceOffset + index, sourceOffset + index + 1);
}
}
addSeparator(sourceOffset: number): void {
this.addChar(" ", sourceOffset, sourceOffset + 1);
}
addChar(char: string, sourceStart: number, sourceEnd: number): void {
if (/\s/.test(char)) {
if (this.text.length > 0 && !this.pendingSpace) {
this.pendingSpace = { sourceStart, sourceEnd };
}
return;
}
if (this.pendingSpace && this.text.length > 0) {
this.text += " ";
this.positions.push(this.pendingSpace);
}
this.pendingSpace = null;
this.text += char;
this.positions.push({ sourceStart, sourceEnd });
}
toProjection(): DocumentTextProjection {
return {
source: this.source,
text: this.text,
positions: this.positions,
};
}
}
+74
View File
@@ -45,6 +45,9 @@ export {
SYSTEM_ISSUE_DOCUMENT_KEYS, SYSTEM_ISSUE_DOCUMENT_KEYS,
isSystemIssueDocumentKey, isSystemIssueDocumentKey,
ISSUE_REFERENCE_SOURCE_KINDS, ISSUE_REFERENCE_SOURCE_KINDS,
DOCUMENT_ANNOTATION_THREAD_STATUSES,
DOCUMENT_ANNOTATION_ANCHOR_STATES,
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES, ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_MONITOR_SCHEDULED_BY, ISSUE_MONITOR_SCHEDULED_BY,
@@ -164,6 +167,9 @@ export {
type IssueTreeHoldStatus, type IssueTreeHoldStatus,
type SystemIssueDocumentKey, type SystemIssueDocumentKey,
type IssueReferenceSourceKind, type IssueReferenceSourceKind,
type DocumentAnnotationThreadStatus,
type DocumentAnnotationAnchorState,
type DocumentAnnotationAnchorConfidence,
type IssueExecutionPolicyMode, type IssueExecutionPolicyMode,
type IssueExecutionStageType, type IssueExecutionStageType,
type IssueMonitorScheduledBy, type IssueMonitorScheduledBy,
@@ -290,6 +296,13 @@ export type {
CompanySkillUsageAgent, CompanySkillUsageAgent,
CompanySkillDetail, CompanySkillDetail,
CompanySkillUpdateStatus, CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
CompanySkillUpdateHoldReason,
CompanySkillAuditFinding,
CompanySkillAuditResult,
CompanySkillInstallUpdateRequest,
CompanySkillResetRequest,
CompanySkillImportRequest, CompanySkillImportRequest,
CompanySkillImportResult, CompanySkillImportResult,
CompanySkillProjectScanRequest, CompanySkillProjectScanRequest,
@@ -299,6 +312,14 @@ export type {
CompanySkillCreateRequest, CompanySkillCreateRequest,
CompanySkillFileDetail, CompanySkillFileDetail,
CompanySkillFileUpdateRequest, CompanySkillFileUpdateRequest,
CatalogSkillKind,
CatalogSkillFileKind,
CatalogSkillFile,
CatalogSkill,
CatalogSkillListQuery,
CatalogSkillFileDetail,
CompanySkillInstallCatalogRequest,
CompanySkillInstallCatalogResult,
AgentSkillSyncMode, AgentSkillSyncMode,
AgentSkillState, AgentSkillState,
AgentSkillOrigin, AgentSkillOrigin,
@@ -376,6 +397,20 @@ export type {
IssueWorkProductProvider, IssueWorkProductProvider,
IssueWorkProductStatus, IssueWorkProductStatus,
IssueWorkProductReviewState, IssueWorkProductReviewState,
CreateDocumentAnnotationCommentRequest,
CreateDocumentAnnotationThreadRequest,
DocumentAnnotationAnchorRemapSnapshot,
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationComment,
DocumentAnnotationTextPositionSelector,
DocumentAnnotationTextQuoteSelector,
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
Issue, Issue,
IssueAssigneeAdapterOverrides, IssueAssigneeAdapterOverrides,
IssueBlockerAttention, IssueBlockerAttention,
@@ -654,6 +689,22 @@ export {
type IssueReferenceMatch, type IssueReferenceMatch,
} from "./issue-references.js"; } from "./issue-references.js";
export {
anchorSnapshotToSelector,
createDocumentAnchorSelector,
normalizeAnchorText,
projectMarkdownToText,
remapDocumentAnchor,
resolveProjectionRange,
selectorToAnchorSnapshot,
verifyDocumentAnchorSelector,
type CreateDocumentAnchorSelectorOptions,
type RemapDocumentAnchorInput,
type RemapDocumentAnchorResult,
type VerifyDocumentAnchorSelectorInput,
type VerifyDocumentAnchorSelectorResult,
} from "./document-anchors.js";
export { export {
sidebarOrderPreferenceSchema, sidebarOrderPreferenceSchema,
upsertSidebarOrderPreferenceSchema, upsertSidebarOrderPreferenceSchema,
@@ -795,6 +846,18 @@ export {
type CreateProjectWorkspace, type CreateProjectWorkspace,
type UpdateProjectWorkspace, type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema, projectExecutionWorkspacePolicySchema,
createDocumentAnnotationCommentSchema,
createDocumentAnnotationThreadSchema,
documentAnnotationAnchorConfidenceSchema,
documentAnnotationAnchorSelectorSchema,
documentAnnotationAnchorStateSchema,
documentAnnotationTextPositionSelectorSchema,
documentAnnotationTextQuoteSelectorSchema,
documentAnnotationThreadStatusSchema,
updateDocumentAnnotationThreadSchema,
type CreateDocumentAnnotationComment,
type CreateDocumentAnnotationThread,
type UpdateDocumentAnnotationThread,
companySearchQuerySchema, companySearchQuerySchema,
COMPANY_SEARCH_DEFAULT_LIMIT, COMPANY_SEARCH_DEFAULT_LIMIT,
COMPANY_SEARCH_MAX_LIMIT, COMPANY_SEARCH_MAX_LIMIT,
@@ -1012,6 +1075,8 @@ export {
companySkillUsageAgentSchema, companySkillUsageAgentSchema,
companySkillDetailSchema, companySkillDetailSchema,
companySkillUpdateStatusSchema, companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
companySkillImportSchema, companySkillImportSchema,
companySkillProjectScanRequestSchema, companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema, companySkillProjectScanSkippedSchema,
@@ -1020,6 +1085,15 @@ export {
companySkillCreateSchema, companySkillCreateSchema,
companySkillFileDetailSchema, companySkillFileDetailSchema,
companySkillFileUpdateSchema, companySkillFileUpdateSchema,
catalogSkillKindSchema,
catalogSkillFileSchema,
catalogSkillSchema,
catalogSkillListQuerySchema,
catalogSkillFileDetailSchema,
companySkillInstallCatalogSchema,
companySkillInstallCatalogResultSchema,
companySkillInstallUpdateSchema,
companySkillResetSchema,
portabilityIncludeSchema, portabilityIncludeSchema,
portabilityEnvInputSchema, portabilityEnvInputSchema,
portabilityCompanyManifestEntrySchema, portabilityCompanyManifestEntrySchema,
@@ -1,4 +1,4 @@
import type { AgentEnvConfig } from "./secrets.js"; import type { AgentEnvConfig, SecretProvider } from "./secrets.js";
import type { RoutineVariable } from "./routine.js"; import type { RoutineVariable } from "./routine.js";
import type { IssueCommentAuthorType } from "../constants.js"; import type { IssueCommentAuthorType } from "../constants.js";
import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js"; import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js";
@@ -20,6 +20,10 @@ export interface CompanyPortabilityEnvInput {
requirement: "required" | "optional"; requirement: "required" | "optional";
defaultValue: string | null; defaultValue: string | null;
portability: "portable" | "system_dependent"; 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 = export type CompanyPortabilityFileEntry =
@@ -179,6 +183,15 @@ export interface CompanyPortabilityManifest {
projects: CompanyPortabilityProjectManifestEntry[]; projects: CompanyPortabilityProjectManifestEntry[];
issues: CompanyPortabilityIssueManifestEntry[]; issues: CompanyPortabilityIssueManifestEntry[];
envInputs: CompanyPortabilityEnvInput[]; envInputs: CompanyPortabilityEnvInput[];
secrets?: CompanyPortabilitySecretEntry[];
}
export interface CompanyPortabilitySecretEntry {
name: string;
provider: SecretProvider;
description: string | null;
latestVersion: number;
currentValue: string;
} }
export interface CompanyPortabilityExportResult { export interface CompanyPortabilityExportResult {
@@ -330,4 +343,5 @@ export interface CompanyPortabilityExportRequest {
selectedFiles?: string[]; selectedFiles?: string[];
expandReferencedSkills?: boolean; expandReferencedSkills?: boolean;
sidebarOrder?: Partial<CompanyPortabilitySidebarOrder>; sidebarOrder?: Partial<CompanyPortabilitySidebarOrder>;
includeSecrets?: boolean;
} }
+108
View File
@@ -51,6 +51,10 @@ export interface CompanySkillListItem {
sourceLabel: string | null; sourceLabel: string | null;
sourceBadge: CompanySkillSourceBadge; sourceBadge: CompanySkillSourceBadge;
sourcePath: string | null; sourcePath: string | null;
catalogKind: "bundled" | "optional" | null;
originHash: string | null;
packageName: string | null;
packageVersion: string | null;
} }
export interface CompanySkillUsageAgent { export interface CompanySkillUsageAgent {
@@ -84,6 +88,49 @@ export interface CompanySkillUpdateStatus {
currentRef: string | null; currentRef: string | null;
latestRef: string | null; latestRef: string | null;
hasUpdate: boolean; hasUpdate: boolean;
installedHash: string | null;
originHash: string | null;
userModifiedAt: string | null;
updateHoldReason: CompanySkillUpdateHoldReason | null;
auditVerdict: CompanySkillAuditVerdict | null;
auditCodes: string[];
}
export type CompanySkillAuditSeverity = "warning" | "error";
export type CompanySkillAuditVerdict = "pass" | "warning" | "fail";
export type CompanySkillUpdateHoldReason =
| "local_modifications"
| "audit_hard_stop"
| "origin_unavailable"
| "compatibility_invalid"
| "operator_hold";
export interface CompanySkillAuditFinding {
code: string;
severity: CompanySkillAuditSeverity;
message: string;
path: string | null;
}
export interface CompanySkillAuditResult {
skillId: string;
installedHash: string | null;
originHash: string | null;
verdict: CompanySkillAuditVerdict;
codes: string[];
findings: CompanySkillAuditFinding[];
scannedAt: string;
scanVersion: string;
}
export interface CompanySkillInstallUpdateRequest {
force?: boolean;
}
export interface CompanySkillResetRequest {
force?: boolean;
} }
export interface CompanySkillImportRequest { export interface CompanySkillImportRequest {
@@ -155,3 +202,64 @@ export interface CompanySkillFileUpdateRequest {
path: string; path: string;
content: string; content: string;
} }
export type CatalogSkillKind = "bundled" | "optional";
export type CatalogSkillFileKind = CompanySkillFileInventoryEntry["kind"];
export interface CatalogSkillFile {
path: string;
kind: CatalogSkillFileKind;
sizeBytes: number;
sha256: string;
}
export interface CatalogSkill {
id: string;
key: string;
kind: CatalogSkillKind;
category: string;
slug: string;
name: string;
description: string;
path: string;
entrypoint: "SKILL.md";
trustLevel: CompanySkillTrustLevel;
compatibility: CompanySkillCompatibility;
defaultInstall: boolean;
recommendedForRoles: string[];
requires: string[];
tags: string[];
files: CatalogSkillFile[];
contentHash: string;
packageName?: string;
packageVersion?: string;
}
export interface CatalogSkillListQuery {
kind?: CatalogSkillKind;
category?: string;
q?: string;
}
export interface CatalogSkillFileDetail {
catalogSkillId: string;
path: string;
kind: CatalogSkillFileKind;
content: string;
language: string | null;
markdown: boolean;
}
export interface CompanySkillInstallCatalogRequest {
catalogSkillId: string;
slug?: string | null;
force?: boolean;
}
export interface CompanySkillInstallCatalogResult {
action: "created" | "updated" | "unchanged";
skill: CompanySkill;
catalogSkill: CatalogSkill;
warnings: string[];
}
@@ -0,0 +1,134 @@
import type {
DocumentAnnotationAnchorConfidence,
DocumentAnnotationAnchorState,
DocumentAnnotationThreadStatus,
IssueCommentAuthorType,
} from "../constants.js";
export interface DocumentTextPosition {
sourceStart: number;
sourceEnd: number;
}
export interface DocumentTextProjection {
source: string;
text: string;
positions: DocumentTextPosition[];
}
export interface DocumentTextRange {
text: string;
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
}
export interface DocumentAnnotationTextQuoteSelector {
exact: string;
prefix: string;
suffix: string;
}
export interface DocumentAnnotationTextPositionSelector {
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
}
export interface DocumentAnnotationAnchorSelector {
quote: DocumentAnnotationTextQuoteSelector;
position: DocumentAnnotationTextPositionSelector;
}
export interface DocumentAnnotationAnchorSnapshot {
selectedText: string;
prefixText: string;
suffixText: string;
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
}
export interface DocumentAnnotationThread {
id: string;
companyId: string;
issueId: string;
documentId: string;
documentKey: string;
status: DocumentAnnotationThreadStatus;
anchorState: DocumentAnnotationAnchorState;
anchorConfidence: DocumentAnnotationAnchorConfidence;
originalRevisionId: string | null;
originalRevisionNumber: number;
currentRevisionId: string | null;
currentRevisionNumber: number;
selectedText: string;
prefixText: string;
suffixText: string;
normalizedStart: number;
normalizedEnd: number;
markdownStart: number;
markdownEnd: number;
anchorSelector: DocumentAnnotationAnchorSelector;
createdByAgentId: string | null;
createdByUserId: string | null;
resolvedByAgentId: string | null;
resolvedByUserId: string | null;
resolvedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentAnnotationComment {
id: string;
companyId: string;
threadId: string;
issueId: string;
documentId: string;
body: string;
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
createdByRunId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface DocumentAnnotationAnchorRemapSnapshot {
id: string;
companyId: string;
threadId: string;
documentId: string;
fromRevisionId: string | null;
fromRevisionNumber: number | null;
toRevisionId: string | null;
toRevisionNumber: number;
previousAnchor: DocumentAnnotationAnchorSnapshot;
nextAnchor: DocumentAnnotationAnchorSnapshot | null;
anchorState: DocumentAnnotationAnchorState;
anchorConfidence: DocumentAnnotationAnchorConfidence;
failureReason: string | null;
createdAt: Date;
}
export interface DocumentAnnotationThreadWithComments extends DocumentAnnotationThread {
comments: DocumentAnnotationComment[];
}
export interface CreateDocumentAnnotationThreadRequest {
baseRevisionId: string;
baseRevisionNumber: number;
selector: DocumentAnnotationAnchorSelector;
body: string;
}
export interface CreateDocumentAnnotationCommentRequest {
body: string;
}
export interface UpdateDocumentAnnotationThreadRequest {
status?: DocumentAnnotationThreadStatus;
}
+31
View File
@@ -51,6 +51,13 @@ export type {
CompanySkillUsageAgent, CompanySkillUsageAgent,
CompanySkillDetail, CompanySkillDetail,
CompanySkillUpdateStatus, CompanySkillUpdateStatus,
CompanySkillAuditSeverity,
CompanySkillAuditVerdict,
CompanySkillUpdateHoldReason,
CompanySkillAuditFinding,
CompanySkillAuditResult,
CompanySkillInstallUpdateRequest,
CompanySkillResetRequest,
CompanySkillImportRequest, CompanySkillImportRequest,
CompanySkillImportResult, CompanySkillImportResult,
CompanySkillProjectScanRequest, CompanySkillProjectScanRequest,
@@ -60,6 +67,14 @@ export type {
CompanySkillCreateRequest, CompanySkillCreateRequest,
CompanySkillFileDetail, CompanySkillFileDetail,
CompanySkillFileUpdateRequest, CompanySkillFileUpdateRequest,
CatalogSkillKind,
CatalogSkillFileKind,
CatalogSkillFile,
CatalogSkill,
CatalogSkillListQuery,
CatalogSkillFileDetail,
CompanySkillInstallCatalogRequest,
CompanySkillInstallCatalogResult,
} from "./company-skill.js"; } from "./company-skill.js";
export type { export type {
AgentSkillSyncMode, AgentSkillSyncMode,
@@ -89,6 +104,22 @@ export type {
AdapterEnvironmentTestResult, AdapterEnvironmentTestResult,
} from "./agent.js"; } from "./agent.js";
export type { AssetImage } from "./asset.js"; export type { AssetImage } from "./asset.js";
export type {
CreateDocumentAnnotationCommentRequest,
CreateDocumentAnnotationThreadRequest,
DocumentAnnotationAnchorRemapSnapshot,
DocumentAnnotationAnchorSelector,
DocumentAnnotationAnchorSnapshot,
DocumentAnnotationComment,
DocumentAnnotationTextPositionSelector,
DocumentAnnotationTextQuoteSelector,
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
} from "./document-annotation.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type { export type {
CompanySearchHighlight, CompanySearchHighlight,
@@ -26,6 +26,9 @@ export const portabilityEnvInputSchema = z.object({
requirement: z.enum(["required", "optional"]), requirement: z.enum(["required", "optional"]),
defaultValue: z.string().nullable(), defaultValue: z.string().nullable(),
portability: z.enum(["portable", "system_dependent"]), 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([ export const portabilityFileEntrySchema = z.union([
@@ -191,6 +194,13 @@ export const portabilityManifestSchema = z.object({
projects: z.array(portabilityProjectManifestEntrySchema).default([]), projects: z.array(portabilityProjectManifestEntrySchema).default([]),
issues: z.array(portabilityIssueManifestEntrySchema).default([]), issues: z.array(portabilityIssueManifestEntrySchema).default([]),
envInputs: z.array(portabilityEnvInputSchema).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", [ export const portabilitySourceSchema = z.discriminatedUnion("type", [
@@ -233,6 +243,7 @@ export const companyPortabilityExportSchema = z.object({
selectedFiles: z.array(z.string().min(1)).optional(), selectedFiles: z.array(z.string().min(1)).optional(),
expandReferencedSkills: z.boolean().optional(), expandReferencedSkills: z.boolean().optional(),
sidebarOrder: portabilitySidebarOrderSchema.partial().optional(), sidebarOrder: portabilitySidebarOrderSchema.partial().optional(),
includeSecrets: z.boolean().optional(),
}); });
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>; export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
@@ -35,6 +35,10 @@ export const companySkillListItemSchema = companySkillSchema.extend({
editableReason: z.string().nullable(), editableReason: z.string().nullable(),
sourceLabel: z.string().nullable(), sourceLabel: z.string().nullable(),
sourceBadge: companySkillSourceBadgeSchema, sourceBadge: companySkillSourceBadgeSchema,
catalogKind: z.enum(["bundled", "optional"]).nullable(),
originHash: z.string().nullable(),
packageName: z.string().nullable(),
packageVersion: z.string().nullable(),
}); });
export const companySkillUsageAgentSchema = z.object({ export const companySkillUsageAgentSchema = z.object({
@@ -64,8 +68,46 @@ export const companySkillUpdateStatusSchema = z.object({
currentRef: z.string().nullable(), currentRef: z.string().nullable(),
latestRef: z.string().nullable(), latestRef: z.string().nullable(),
hasUpdate: z.boolean(), hasUpdate: z.boolean(),
installedHash: z.string().nullable(),
originHash: z.string().nullable(),
userModifiedAt: z.string().nullable(),
updateHoldReason: z.enum([
"local_modifications",
"audit_hard_stop",
"origin_unavailable",
"compatibility_invalid",
"operator_hold",
]).nullable(),
auditVerdict: z.enum(["pass", "warning", "fail"]).nullable(),
auditCodes: z.array(z.string()),
}); });
export const companySkillAuditFindingSchema = z.object({
code: z.string().min(1),
severity: z.enum(["warning", "error"]),
message: z.string().min(1),
path: z.string().nullable(),
});
export const companySkillAuditResultSchema = z.object({
skillId: z.string().uuid(),
installedHash: z.string().nullable(),
originHash: z.string().nullable(),
verdict: z.enum(["pass", "warning", "fail"]),
codes: z.array(z.string()),
findings: z.array(companySkillAuditFindingSchema),
scannedAt: z.string().min(1),
scanVersion: z.string().min(1),
});
export const companySkillInstallUpdateSchema = z.object({
force: z.boolean().optional(),
}).default({});
export const companySkillResetSchema = z.object({
force: z.boolean().optional(),
}).default({});
export const companySkillImportSchema = z.object({ export const companySkillImportSchema = z.object({
source: z.string().min(1), source: z.string().min(1),
}); });
@@ -131,7 +173,70 @@ export const companySkillFileUpdateSchema = z.object({
content: z.string(), content: z.string(),
}); });
export const catalogSkillKindSchema = z.enum(["bundled", "optional"]);
export const catalogSkillFileSchema = z.object({
path: z.string().min(1),
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
sizeBytes: z.number().int().nonnegative(),
sha256: z.string().min(1),
});
export const catalogSkillSchema = z.object({
id: z.string().min(1),
key: z.string().min(1),
kind: catalogSkillKindSchema,
category: z.string().min(1),
slug: z.string().min(1),
name: z.string().min(1),
description: z.string(),
path: z.string().min(1),
entrypoint: z.literal("SKILL.md"),
trustLevel: companySkillTrustLevelSchema,
compatibility: companySkillCompatibilitySchema,
defaultInstall: z.boolean(),
recommendedForRoles: z.array(z.string()),
requires: z.array(z.string()),
tags: z.array(z.string()),
files: z.array(catalogSkillFileSchema),
contentHash: z.string().min(1),
packageName: z.string().min(1).optional(),
packageVersion: z.string().min(1).optional(),
});
export const catalogSkillListQuerySchema = z.object({
kind: catalogSkillKindSchema.optional(),
category: z.string().min(1).optional(),
q: z.string().min(1).optional(),
});
export const catalogSkillFileDetailSchema = z.object({
catalogSkillId: z.string().min(1),
path: z.string().min(1),
kind: z.enum(["skill", "markdown", "reference", "script", "asset", "other"]),
content: z.string(),
language: z.string().nullable(),
markdown: z.boolean(),
});
export const companySkillInstallCatalogSchema = z.object({
catalogSkillId: z.string().min(1),
slug: z.string().min(1).nullable().optional(),
force: z.boolean().optional(),
});
export const companySkillInstallCatalogResultSchema = z.object({
action: z.enum(["created", "updated", "unchanged"]),
skill: companySkillSchema,
catalogSkill: catalogSkillSchema,
warnings: z.array(z.string()),
});
export type CompanySkillImport = z.infer<typeof companySkillImportSchema>; export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>; export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>; export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>; export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
export type CatalogSkillListQuery = z.infer<typeof catalogSkillListQuerySchema>;
export type CompanySkillInstallCatalog = z.infer<typeof companySkillInstallCatalogSchema>;
export type CompanySkillInstallUpdate = z.infer<typeof companySkillInstallUpdateSchema>;
export type CompanySkillReset = z.infer<typeof companySkillResetSchema>;
@@ -0,0 +1,65 @@
import { z } from "zod";
import {
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
DOCUMENT_ANNOTATION_ANCHOR_STATES,
DOCUMENT_ANNOTATION_THREAD_STATUSES,
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
export const documentAnnotationThreadStatusSchema = z.enum(DOCUMENT_ANNOTATION_THREAD_STATUSES);
export const documentAnnotationAnchorStateSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_STATES);
export const documentAnnotationAnchorConfidenceSchema = z.enum(DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES);
export const documentAnnotationTextQuoteSelectorSchema = z.object({
exact: z.string().min(1).max(10_000),
prefix: z.string().max(1_000).default(""),
suffix: z.string().max(1_000).default(""),
}).strict();
export const documentAnnotationTextPositionSelectorSchema = z.object({
normalizedStart: z.number().int().nonnegative(),
normalizedEnd: z.number().int().nonnegative(),
markdownStart: z.number().int().nonnegative(),
markdownEnd: z.number().int().nonnegative(),
}).strict().superRefine((value, ctx) => {
if (value.normalizedEnd <= value.normalizedStart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "normalizedEnd must be greater than normalizedStart",
path: ["normalizedEnd"],
});
}
if (value.markdownEnd <= value.markdownStart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "markdownEnd must be greater than markdownStart",
path: ["markdownEnd"],
});
}
});
export const documentAnnotationAnchorSelectorSchema = z.object({
quote: documentAnnotationTextQuoteSelectorSchema,
position: documentAnnotationTextPositionSelectorSchema,
}).strict();
export const createDocumentAnnotationThreadSchema = z.object({
baseRevisionId: z.string().uuid(),
baseRevisionNumber: z.number().int().positive(),
selector: documentAnnotationAnchorSelectorSchema,
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
}).strict();
export const createDocumentAnnotationCommentSchema = z.object({
body: multilineTextSchema.pipe(z.string().min(1).max(20_000)),
}).strict();
export const updateDocumentAnnotationThreadSchema = z.object({
status: documentAnnotationThreadStatusSchema.optional(),
}).strict().refine((value) => value.status != null, {
message: "At least one field must be provided",
});
export type CreateDocumentAnnotationThread = z.infer<typeof createDocumentAnnotationThreadSchema>;
export type CreateDocumentAnnotationComment = z.infer<typeof createDocumentAnnotationCommentSchema>;
export type UpdateDocumentAnnotationThread = z.infer<typeof updateDocumentAnnotationThreadSchema>;
+30
View File
@@ -67,6 +67,8 @@ export {
companySkillUsageAgentSchema, companySkillUsageAgentSchema,
companySkillDetailSchema, companySkillDetailSchema,
companySkillUpdateStatusSchema, companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,
companySkillAuditResultSchema,
companySkillImportSchema, companySkillImportSchema,
companySkillProjectScanRequestSchema, companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema, companySkillProjectScanSkippedSchema,
@@ -75,10 +77,23 @@ export {
companySkillCreateSchema, companySkillCreateSchema,
companySkillFileDetailSchema, companySkillFileDetailSchema,
companySkillFileUpdateSchema, companySkillFileUpdateSchema,
catalogSkillKindSchema,
catalogSkillFileSchema,
catalogSkillSchema,
catalogSkillListQuerySchema,
catalogSkillFileDetailSchema,
companySkillInstallCatalogSchema,
companySkillInstallCatalogResultSchema,
companySkillInstallUpdateSchema,
companySkillResetSchema,
type CompanySkillImport, type CompanySkillImport,
type CompanySkillProjectScan, type CompanySkillProjectScan,
type CompanySkillCreate, type CompanySkillCreate,
type CompanySkillFileUpdate, type CompanySkillFileUpdate,
type CatalogSkillListQuery,
type CompanySkillInstallCatalog,
type CompanySkillInstallUpdate,
type CompanySkillReset,
} from "./company-skill.js"; } from "./company-skill.js";
export { export {
agentSkillStateSchema, agentSkillStateSchema,
@@ -152,6 +167,21 @@ export {
type ProjectExecutionWorkspacePolicy, type ProjectExecutionWorkspacePolicy,
} from "./project.js"; } from "./project.js";
export {
createDocumentAnnotationCommentSchema,
createDocumentAnnotationThreadSchema,
documentAnnotationAnchorConfidenceSchema,
documentAnnotationAnchorSelectorSchema,
documentAnnotationAnchorStateSchema,
documentAnnotationTextPositionSelectorSchema,
documentAnnotationTextQuoteSelectorSchema,
documentAnnotationThreadStatusSchema,
updateDocumentAnnotationThreadSchema,
type CreateDocumentAnnotationComment,
type CreateDocumentAnnotationThread,
type UpdateDocumentAnnotationThread,
} from "./document-annotation.js";
export { export {
createIssueSchema, createIssueSchema,
createIssueInputSchema, createIssueInputSchema,
@@ -0,0 +1,75 @@
---
name: doc-maintenance
description: Keep project docs aligned with recent code and feature changes — detect drift, update affected pages, and add release-relevant notes without rewriting unchanged sections.
key: paperclipai/bundled/docs/doc-maintenance
recommendedForRoles:
- engineer
- product
- devrel
tags:
- docs
- documentation
- release-notes
---
# Doc Maintenance
Keep the documentation honest with minimum churn. The goal is alignment between docs and behavior, not stylistic rewrites or cosmetic re-organization. Reviewers should be able to read a diff and see "this updates docs to match recent behavior changes".
## When to use
- A PR or recent set of merges changed user-visible behavior: CLI flags, API shapes, default values, configuration keys, endpoints, environment variables, supported versions.
- A user-reported bug traced back to outdated documentation.
- A release is being cut and the docs need a pass against the merged commits.
- A new feature shipped but only the engineer's PR description describes how to use it.
## When not to use
- The change is internal-only (private helper rename, refactor) with no user-visible impact.
- You want to "improve the docs" without a behavior anchor. That is a separate scoped project, not maintenance — make a plan first.
## The pass
1. **Establish the baseline.** Get the commit range you are documenting against (since last release tag, since last merged-doc commit, or since a specific PR).
2. **Enumerate user-visible changes.** Read commits and PR descriptions. List, for each change, what a user can now do differently.
3. **Map changes to docs.** For each change, find every page that mentions the affected concept. Common targets: README, CLI reference, API reference, configuration reference, migration guide, FAQ, examples.
4. **Update precisely.** Edit only the lines that need to change. Do not rewrap paragraphs you did not modify — it pollutes the diff.
5. **Add new entries where needed.** New CLI flag → CLI reference entry. New env var → configuration reference entry. New endpoint → API reference entry. Don't only add it to the changelog.
6. **Update examples and snippets.** Code blocks in docs are wrong faster than prose. Re-run any example that touches new behavior.
7. **Write the release note.** One sentence per user-visible change. Group by Added / Changed / Fixed / Deprecated / Removed. Link to the relevant PRs and docs section.
8. **Cross-check.** Search the docs for the old behavior wording and remove or update stragglers.
## Style baseline
- Voice: second person ("you can pass `--json` to ..."). Avoid "we" except in narrative pages.
- Tense: present, not future. The behavior exists once shipped.
- Headings: imperative ("Configure the cache") or noun-phrase ("Cache configuration"), match the surrounding page.
- Code blocks: include the language tag so syntax highlighting works.
- Cross-links: link the first mention of a concept on each page; do not link every occurrence.
- Avoid promising future behavior. If something is unreleased, mark it `experimental` or omit it.
## Drift detection
A doc page is drifting if any of these are true:
- It documents a flag, key, or endpoint that no longer exists.
- An example does not run as written.
- A default value in the docs does not match the code.
- A supported-versions list excludes a version the project actually supports, or includes one it dropped.
- A "Coming soon" section references a feature that shipped or was cancelled.
When you find drift, fix it in the same pass and note it in the release note's `Fixed` group.
## Release-note rules
- One sentence per item. If two sentences are needed, the item is likely two items.
- User impact first, internal cause second. `Faster cold start (avoid full bundle download on first run)` beats `Refactor bootstrap loader`.
- Link the PR for engineering readers and the docs page for users.
- Mark breaking changes explicitly: `**Breaking:**` prefix. Include migration steps inline or via link.
## Anti-patterns
- Massive doc PRs that bundle stylistic rewrites with real updates. Reviewers cannot tell which lines reflect actual behavior changes.
- "Updated docs" commit messages with no detail. Make the commit say what changed and why.
- Adding to the changelog without updating the reference docs the changelog points to.
- Marking a feature as available before its code lands. Documentation must follow behavior, not promise it.
@@ -0,0 +1,74 @@
---
name: issue-triage
description: Triage Paperclip inbox issues that are stale, blocked, in-review, or assigned-but-not-progressing, and decide a single next action per issue (resume, reassign, unblock, escalate, or close).
key: paperclipai/bundled/paperclip-operations/issue-triage
recommendedForRoles:
- manager
- ceo
- engineer
tags:
- paperclip
- triage
- inbox
- workflow
---
# Issue Triage
Convert a noisy inbox into a small set of clear next actions. Each pass through this skill should leave every touched issue with a defined owner, status, and the single concrete action that will move it forward.
## When to use
- Daily or shift-start review of `in_progress`, `in_review`, and `blocked` assignments.
- An inbox has many open assignments and no clear priority.
- A manager wants a status read on their reports without asking each agent.
- You are woken by a comment that suggests an old issue stalled.
## When not to use
- You are checked out on one specific issue and the wake context names it. Work that issue, do not triage the whole inbox.
- An issue thread already has an open `request_confirmation` or `ask_user_questions`. Wait for the response — re-triage is noise.
## Inputs
- `GET /api/agents/me/inbox-lite` for the compact assignment list.
- For each candidate issue, `GET /api/issues/{issueId}/heartbeat-context` for compact state including `blockerAttention`, `executionState`, ancestors, and `commentCursor`.
- Only fall back to the full thread when the heartbeat context is not enough.
## Per-issue triage decision
For each issue, classify into exactly one of:
1. **Resume** — execution path is alive. Confirm the assignee is set and let the heartbeat continue. Do not comment.
2. **Wake-needed** — assignee is stalled with no live continuation. Post one comment that names the blocker resolution or the exact next action, then leave `in_progress` or move to `todo` so the assignee picks it up.
3. **Reassign** — the assignee is not the right specialty. Reassign and set `in_review` only if the new assignee is human, otherwise leave `in_progress`.
4. **Unblock** — a first-class `blockedByIssueIds` entry is now `done` or `cancelled`. If `cancelled`, replace or remove it from `blockedByIssueIds`. The blockers-resolved wake will fire automatically when all are `done`.
5. **Escalate** — the issue needs board, CTO, or user input. Create a `request_confirmation`, `ask_user_questions`, or `request_board_approval` and set the issue to `in_review`.
6. **Close** — work is complete, duplicate, or no longer relevant. Set `done` or `cancelled` with a one-line reason.
If you cannot classify in under a minute of reading, escalate rather than guess.
## Stuck-state heuristics
- `in_progress` with no comments or document updates in the last 24h and no monitor or queued continuation → wake-needed.
- `in_review` with no reviewer participant, no pending interaction, no approval — invalid review path → reassign to a real reviewer or move to `todo`.
- `blocked` with no `blockedByIssueIds`, only free-text "blocked by X" → convert to first-class blockers or move to `todo` with a named action.
- `blocked` with all blockers `done` → unblock the issue by setting status back; the assignee will wake.
- Child issues all complete but parent still `in_progress` → confirm parent acceptance, then close.
## Don't-do list
- Do not @-mention agents during triage; mentions cost budget. Use direct reassignment instead.
- Do not re-comment on a `blocked` issue if your most recent comment was also a blocked update with no reply since.
- Do not cancel cross-team issues. Reassign to the responsible manager with a comment.
- Do not change status without a comment that explains the change.
## Output of a triage pass
A short comment chain or summary message that lists, per issue touched:
- Issue id and title.
- Verdict (resume / wake-needed / reassign / unblock / escalate / close).
- The one action you took or asked for.
This is the bar for "the triage is done."
@@ -0,0 +1,84 @@
---
name: task-planning
description: Turn a Paperclip issue or request into a structured implementation plan with child task graph, blockers, owners, and acceptance criteria, then save it as the issue `plan` document.
key: paperclipai/bundled/paperclip-operations/task-planning
recommendedForRoles:
- manager
- engineer
- product
tags:
- paperclip
- planning
- issues
- delegation
---
# Task Planning
Produce implementation plans that the Paperclip executor can actually run: explicit child issues, real blockers, named owners, and a defined acceptance bar. Avoid plans that read well but cannot be split into work.
## When to use
- An issue asks you to "plan", "scope", "break down", "design the rollout", "propose the work", or similar.
- A user wants a written plan before approving implementation.
- A manager needs to delegate non-trivial work and the shape of the work is not obvious yet.
- You inherited an issue too large to deliver in one heartbeat and need to split it.
## When not to use
- The issue is a single small change you can ship in the same heartbeat. Just ship it.
- The issue is forensic ("why did this break"). Use a diagnosis skill first; plan only after the root cause is named.
- A current `plan` document already exists and the change is minor. Update that document; do not start fresh.
## Outputs
1. An updated issue document with key `plan` (markdown).
2. A short comment on the issue that links to the plan document and names the next action.
3. Where the plan requires approval, an issue-thread interaction of kind `request_confirmation` bound to the latest plan revision.
Do not create implementation subtasks until the plan is accepted.
## Plan structure
Required sections, in order:
1. **Goal** — one paragraph. What changes for the user, the operator, or the system once this work lands.
2. **Context reviewed** — bullet list of documents, files, and prior issues you read. Lets reviewers spot missing inputs.
3. **Constraints and non-goals** — what must hold (compatibility, security, performance) and what this plan deliberately will not do.
4. **Approach** — the chosen path, with a short rationale. If you considered alternatives, name them and why you rejected them.
5. **Work breakdown** — ordered list of child issues. Each child has:
- Title in imperative form.
- Owner specialty (Engineer, QA, Designer, Security, DevRel, Manager, etc.).
- Scope and deliverables.
- Acceptance criteria.
- Blocks/blocked-by relationships expressed by phase letter or child title.
6. **Acceptance** — the bar for the parent issue. How the user knows the whole thing is done.
7. **Risks and mitigations** — short list. Skip if there are none.
8. **Deferrals** — what is intentionally pushed to follow-up issues, with why.
## Rules of thumb for splitting
- One child issue, one specialty. If two specialties have to coordinate inside the same issue, split it.
- One child issue, one acceptance verdict. If a reviewer would say "this is half done", split it.
- A child must be checkout-able by the owner from its title and description alone. Reviewers should not have to re-read the parent plan to understand a child.
- Order children by real blocker chains, not by author preference. Parallel children should explicitly say `blockers: none`.
- Avoid `polish` or `cleanup` child issues without acceptance criteria — they never close.
## Filing the plan
Use the Paperclip API to write the plan document, then comment:
- `PUT /api/issues/{issueId}/documents/plan` with the markdown body. If `plan` already exists, include the latest `baseRevisionId`.
- `POST /api/issues/{issueId}/comments` with a short summary that links the plan: `/<prefix>/issues/<issue-id>#document-plan`.
- If approval is required: `POST /api/issues/{issueId}/interactions` with `kind: request_confirmation`, `targetRevisionId` set to the new plan revision, `continuationPolicy: wake_assignee`, and `idempotencyKey: "confirmation:{issueId}:plan:{revisionId}"`.
- Set the issue to `in_review` after creating the confirmation. Stay assigned so the acceptance wakes the planner.
When the plan is accepted, see the companion skill for converting accepted plans into Paperclip executable tasks.
## Anti-patterns
- Plan disguised as a description edit. Use the `plan` document.
- "Phases AZ" with no work breakdown inside the phases.
- Children with descriptions that say "see parent" — they fail at delegation time.
- Acceptance written as "code review approval". Reviewers need a behavior bar, not a process bar.
- Plans that bury blocker chains in prose. Use explicit blocked-by lines.
@@ -0,0 +1,93 @@
---
name: qa-acceptance
description: Produce QA acceptance criteria and a manual validation plan for a feature change — golden path, edge cases, error states, performance limits, and explicit pass/fail evidence.
key: paperclipai/bundled/quality/qa-acceptance
recommendedForRoles:
- qa
- engineer
- product
tags:
- qa
- acceptance
- validation
- testing
---
# QA Acceptance
Write acceptance criteria that a reviewer can run against the running app and decide pass or fail without asking the author. The criteria are the contract — automated tests cover correctness, QA covers feature-level behavior.
## When to use
- A feature change is heading to QA and needs a written validation plan.
- A reviewer is asked to verify a PR that touches user-visible behavior.
- An incident postmortem requires a regression check before reopen-prevention.
- A release candidate needs a pre-cut smoke pass.
## When not to use
- The change is unit-test-only (utility refactor, internal naming). Acceptance criteria are unnecessary churn.
- You are asked to write tests against API contracts. Use contract testing, not feature QA.
## Acceptance criteria format
Each criterion is a single, independently-verifiable statement:
```md
- **Given** <starting state>, **when** <action>, **then** <observable outcome>.
```
Example:
```md
- **Given** a CSV export with 0 rows, **when** the user clicks Export, **then** the file downloads with only the header row and the UI shows "Exported 0 rows".
```
Avoid criteria that combine multiple `when`s or `then`s. Split them.
## What every plan must cover
1. **Golden path.** The most common successful flow, end to end.
2. **Empty and minimum states.** Zero items, one item, missing optional inputs.
3. **Boundary inputs.** Max length strings, max numeric values, unicode, RTL text where applicable.
4. **Error states.** Network failure, permission denied, validation failures, conflict (409), not found (404).
5. **Concurrency and ordering.** Two users acting at once, race against background jobs, refresh during mutation.
6. **Performance envelope.** The largest realistic input the change must handle without UI hangs or timeouts.
7. **Backward compatibility.** Existing data, existing URLs, persisted user preferences continue to work.
8. **Telemetry and audit.** Events, logs, or activity entries the change is supposed to emit.
If a section is genuinely not applicable, write "N/A: <why>" — do not silently omit.
## Evidence
Each criterion needs evidence on the verification pass:
- Screenshot or short clip for UI behavior.
- Copied console / network output for API behavior.
- Log snippet or activity row for telemetry.
- Timing measurement for performance criteria.
"Looks good to me" without evidence is not a pass.
## Quarantine and follow-up
- A failing criterion blocks acceptance unless explicitly waived by the owner with a tracked follow-up issue.
- "Known issue" without a linked follow-up is not a waiver.
- If you add a new criterion mid-pass, restart the pass — partial coverage hides regressions.
## Handoff back to the author
Return the validation plan with three sections:
- **Pass.** Criteria that passed, with one-line evidence summaries.
- **Fail.** Criteria that failed, with the exact reproduction.
- **Blocked.** Criteria you could not run, with why.
The author owns turning failures into either fixes or accepted deferrals.
## Anti-patterns
- Acceptance phrased as test plan ("write a Cypress test for X"). Acceptance is what is true after the change ships; tests are how you check.
- Criteria that depend on inspecting implementation details (selectors, query plans). Stay observable.
- Long checklists with no priority. Mark must-pass criteria distinctly from nice-to-have.
- Validation reports that say "passed" with no evidence. Reviewers cannot audit those.
@@ -0,0 +1,93 @@
---
name: github-pr-workflow
description: Prepare a GitHub pull request from a feature branch — branch hygiene, commit shape, title/body, verification notes, screenshots for UI work, and replies to review comments.
key: paperclipai/bundled/software-development/github-pr-workflow
recommendedForRoles:
- engineer
tags:
- github
- pull-requests
- code-review
- release
---
# GitHub Pull Request Workflow
Ship a PR a reviewer can land without follow-up clarifying questions. The aim is high signal in the title and body, evidence the change works, and clean replies when feedback comes in.
## When to use
- You are about to open a PR for a change that is functionally complete.
- A reviewer left comments and you need to respond and push fixes.
- A PR has been open more than a day and needs to be brought back into shape (stale conflicts, missing description, missing verification).
## When not to use
- The change is not yet functionally complete. Finish the work first; draft PRs that bounce on review are noise.
- The repository uses a non-GitHub forge. Adjust to that forge's conventions; do not force GitHub-isms.
## Branch hygiene before opening
- Rebase or merge from the target base so the diff is current.
- Squash WIP commits into reviewable units. Prefer one commit per logical change; do not force one-commit-per-PR if the work is genuinely multi-step.
- Confirm tests, typecheck, and lint pass locally. Note any deliberate skips in the PR body.
- Remove debug prints, commented-out code, and `TODO` markers that are not tracked.
## PR title
- Imperative mood, under 70 characters.
- Lead with the user-visible change, not the file touched. `Allow CSV export from reports table` beats `Update reports.tsx`.
- If the repo uses an issue prefix convention (`PAP-1234:`, `[security]`), follow it.
- No trailing period.
## PR body
Use this structure:
```md
## Summary
- 13 bullets describing what changed and why.
## Implementation notes
- Anything non-obvious in the diff: trade-offs, dropped alternatives, gotchas.
- Migration or config implications.
## Verification
- The exact commands or steps you ran.
- Screenshots or short clips for UI changes (required if pixels moved).
- Edge cases you exercised by hand.
## Risk and rollback
- What breaks if this is reverted, and how to revert cleanly.
```
Skip the `Risk and rollback` section only for clearly trivial PRs (typos, docs).
## Verification evidence
- Tests passing in CI is necessary, not sufficient. Reviewers also need to know the change behaves correctly end to end.
- For UI work, include screenshots of the golden path and one edge case. Tag dark and light mode if the project supports both.
- For migrations, include a dry-run plan and reversal steps.
- For performance changes, include a before/after measurement, not adjectives.
## Replying to review comments
- Reply on every comment, even with just "fixed in <commit-sha>" — silent fixes leave the reviewer guessing.
- Push fixes as new commits while review is active; do not amend during review unless the reviewer agrees.
- If you disagree with feedback, say so with one sentence of rationale and let the reviewer decide. Don't escalate over comments.
- Re-request review explicitly after pushing changes.
## Merge checklist
- All required checks green.
- All review comments resolved.
- PR title/body still accurate (update if scope changed mid-review).
- Linked issue moves to `in_review` or `done` per project convention.
- Delete the branch after merge unless it is a long-lived integration branch.
## Anti-patterns
- PR description that says "see commits". Reviewers should not need to read the log.
- Mixing refactor and behavior change in the same PR with no separation in the body.
- "Address feedback" commits that bundle unrelated edits. One commit per round of feedback is fine; one commit for everything in flight is not.
- Force-pushing during active review without telling the reviewer.
@@ -0,0 +1,93 @@
---
name: agent-browser
description: Drive a real browser to inspect or interact with a web page or app — navigate, take screenshots, read console and network, fill simple forms — for verification tasks, not unattended automation.
key: paperclipai/optional/browser/agent-browser
recommendedForRoles:
- qa
- engineer
- researcher
tags:
- browser
- puppeteer
- playwright
- verification
---
# Agent Browser
Use a controlled browser to verify behavior, capture evidence, or extract information from web pages that a static fetch cannot reach (SPAs, login-gated pages, dynamic content). This skill is about supervised verification, not unattended scraping.
## When to use
- You need a screenshot of a deployed page or a local dev server to confirm a UI change.
- You need to read JavaScript-rendered content that `curl`/`wget` will not see.
- A user reports a UI bug and you need to reproduce it interactively to capture console errors, network requests, or layout state.
- You need to walk through a short flow (load page, click, observe) to verify acceptance criteria.
## When not to use
- The page is reachable as static HTML. Use `curl`/HTTP fetch — it is cheaper, faster, and more reliable.
- The task is unattended large-scale scraping. That belongs to a dedicated scraper with rate limits, robots.txt handling, and a real user agent policy — not this skill.
- The site is behind authentication you do not own credentials for, or whose terms of service prohibit automation.
- The site involves sensitive accounts (banking, healthcare, government) where automation risks lockout or compliance issues.
## Before launching the browser
- Confirm the URL and what state should be true after navigation.
- Decide what evidence is needed: full-page screenshot, viewport screenshot, console log, network trace, HTML snapshot, extracted text.
- Decide the viewport size that matters for the task (mobile vs desktop). Default to a desktop size unless the task is mobile-specific.
- For local dev servers, confirm the server is running and the port is what you expect.
## Driving the browser
A typical verification session:
1. **Launch with a real-looking user agent** when the target is the public internet; an unrealistic UA flags automation traffic.
2. **Set a sane viewport** (e.g., 1366×768 desktop, 390×844 iPhone-ish).
3. **Navigate and wait for the right signal.** Prefer waiting for a specific selector or network-idle over arbitrary sleeps.
4. **Capture evidence immediately** after the wait condition succeeds, before any interaction perturbs the state.
5. **Interact deliberately.** One click at a time, with a wait between actions; re-screenshot after each meaningful state change.
6. **Read the console and network panels** for unexpected errors, 4xx/5xx responses, or slow requests.
7. **Close the browser cleanly** when done. Long-running browser sessions leak memory and hold ports.
## What evidence to record
For a verification task, deliver:
- A full-page or viewport screenshot of each meaningful state.
- The console log, filtered to warnings/errors.
- Any non-2xx network response with the URL, status, and a short response body excerpt.
- A short narration: "Navigated to X, observed Y, clicked Z, observed W."
For a UI bug repro, also record:
- The exact reproduction steps the user can follow.
- Viewport size and (where relevant) device pixel ratio.
- Whether the bug reproduces on first load vs after interaction.
## Login-gated pages
- Prefer programmatic auth (API token, magic link) over UI login.
- If UI login is the only path, the user must provide credentials explicitly for this run. Never reuse credentials outside the session.
- Do not store credentials in the session log, screenshot, or returned output.
## Performance and politeness
- Throttle to one navigation per few seconds when touching shared infra.
- Respect `robots.txt` for public sites you are inspecting at any volume.
- Cancel navigations if a page exceeds a reasonable timeout (e.g., 30s); the page is broken or rate-limiting you.
- Do not retry forever on failure. Retry once with a longer timeout, then escalate.
## Common failure modes
- **Selector not found.** Page changed, or you are waiting before render. Take a screenshot to see actual state; adjust the selector.
- **Click does nothing.** The element is offscreen, covered by a modal, or in a shadow DOM. Scroll into view or pierce the shadow root.
- **Headless detection.** Some sites detect headless Chrome and serve a different page. Use a non-headless mode or a fingerprint-realistic configuration only when authorized.
- **Cross-origin iframe blocking.** Iframes you do not own cannot be inspected; the page must offer the data outside the iframe or the task is infeasible.
## Anti-patterns
- Long unsupervised browser sessions that drift from the original task.
- Scraping behind authentication you do not own.
- Captioning a screenshot with "looks good" without saying what state was loaded and what selectors confirmed it.
- Treating a passing screenshot as proof of correctness across viewports you did not actually test.
@@ -0,0 +1,128 @@
---
name: release-announcement
description: Write a release announcement — changelog, blog post, in-app note, or social post — that leads with user impact, names the audience, and includes upgrade/migration steps without filler.
key: paperclipai/optional/content/release-announcement
recommendedForRoles:
- devrel
- product
- writer
tags:
- release
- changelog
- announcement
- communication
---
# Release Announcement
Write the channel-appropriate announcement for a release without churn. Different surfaces need different shapes: a changelog entry is not a blog post is not a social card. The bar is: a reader of the chosen surface can decide in under 30 seconds whether this release affects them, and if so what to do.
## When to use
- A version, feature, or fix is shipping and needs writeup for at least one surface.
- A previously private feature is going GA.
- A breaking change needs broadcast before users hit it.
## When not to use
- An internal-only change with no user impact. Update internal docs; do not announce.
- The release is incomplete (still in active development). Wait until it ships, even if marketing wants the post.
## Determine the audience and channel first
| Audience | Best channel | Tone |
|---|---|---|
| Existing power users | Changelog, in-app note | Terse, factual, links |
| Engineering teams adopting your API | Release notes, dev blog | Examples, migration steps, version pins |
| Prospective customers | Landing page, marketing blog | Story arc, problem → solution, social proof |
| Broad audience | Social post, email newsletter | One-sentence pitch, link to depth |
| Internal team | Slack/Discord post | What changed, who to ping if it breaks |
Pick the audience for *this* writeup. One release often needs several writeups; do not blend them.
## Universal structure
Whatever the channel, lead with:
1. **What changed.** One sentence in the user's vocabulary.
2. **Who it affects.** Which user role / use case.
3. **What to do.** Migrate now / opt-in / no action needed.
Everything else is depth that supports those three.
## Channel templates
### Changelog entry (terse)
```md
## v1.42.0 — 2026-05-26
### Added
- <feature> — <one-line user benefit>. ([#1234](link))
### Changed
- <change> — <one-line impact>. ([#1235](link))
### Fixed
- <bug> — <one-line user-visible symptom>. ([#1236](link))
### Deprecated
- <thing>. Replaced by <thing>. Removal planned for v<x>.
### Breaking
- <change>. **Migration:** <one-line> or <link to guide>.
```
### Release notes (for adopters)
Same as changelog, plus:
- Migration guide section with before/after code.
- Compatibility table (versions, runtimes, OS).
- Known issues and workarounds.
- Acknowledgements (contributors, reporters of fixed bugs).
### Dev blog post (300800 words)
- **Hook (1 paragraph):** the problem the release solves, in a real-world scenario.
- **What's new (35 bullets with sub-paragraphs):** features, with one code or screenshot example each.
- **Upgrade (1 paragraph):** how to upgrade, what to check.
- **What's next:** one sentence about the next direction. Avoid promises.
### In-app note
- 1 sentence.
- 1 link.
- Dismiss after seen.
### Social post
- 1 sentence pitch.
- 1 link.
- 1 image or short clip.
- No threadbait. If it needs a thread, write a blog post instead.
## Writing rules
- Lead with the user, not the team. `You can now export to CSV` beats `We've added CSV export`.
- Numbers beat adjectives. `60% faster cold start` beats `much faster`. Cite the methodology.
- Show, don't just tell. One code snippet, one screenshot — more is noise.
- Date the post. Undated release content rots fastest.
- Link the migration path explicitly. Do not bury it.
- Mark breaking changes with `**Breaking:**` prefix. Repeat in the email/social channel.
## Avoid
- "We are excited to announce" filler.
- Lists of changes that mix user-visible and internal items.
- Marketing claims without a way to verify.
- Promised dates for unshipped work.
- Pre-announcing something the team has not yet committed to ship.
## Post-publish checklist
- Changelog is in source control alongside the release.
- Blog post date matches actual ship date.
- All links work (release tag, PRs, docs sections).
- Breaking changes are also in the upgrade guide, not only the post.
- Internal team is notified before the public post goes live, not after.
@@ -0,0 +1,121 @@
---
name: design-critique
description: Give a structured product design critique — user job clarity, hierarchy, affordance, error states, accessibility, and consistency — focused on what to change, in what order, and why.
key: paperclipai/optional/product/design-critique
recommendedForRoles:
- designer
- product
- engineer
tags:
- design
- product
- ux
- review
---
# Product Design Critique
A structured critique pass for a screen, flow, or component. The output is a prioritized list of changes a designer or engineer can act on — not adjectives. Critique is not redesign; recommend, do not rebuild.
## When to use
- A designer or engineer asks for feedback on a screen, mock, or live UI.
- A feature is shipping and someone wants a final UX read.
- A flow is suspected of causing user drop-off and you want a pre-research read before instrumentation.
## When not to use
- The user wants a redesign. That is a design project, not a critique.
- The work is so early that no concrete artifact exists. Sketch with them instead of critiquing air.
- You have no context on the user job. Ask for it first; design critique without user context devolves into taste.
## Pre-critique context
Before opening a screen, get:
- **Who is the user.** Specific role and competence, not "users".
- **What job they are doing on this screen.** One sentence.
- **What success looks like.** What the user can do after this screen that they could not before.
- **Where this screen sits in the larger flow.** What precedes and follows.
If any of these is missing, ask. Critique without these is opinion.
## The pass (in order)
1. **Clarity of the user job.**
- Within 3 seconds of opening, is it obvious what this screen is for?
- Does the primary action match the user's actual job, or a designer's preferred path?
2. **Visual hierarchy.**
- The most important thing on the screen should be the most prominent (size, weight, position, color).
- Secondary actions should look secondary. Tertiary should be findable but not loud.
- Headings should chunk content into the right groups for the task.
3. **Affordance and signifiers.**
- Clickable things look clickable.
- Disabled things look disabled and explain why on hover/focus.
- Drag, scroll, or swipe interactions are discoverable, not hidden.
4. **States.**
- Empty state (no data) is designed, not a blank rectangle.
- Loading state communicates progress, not just spins.
- Error states say what went wrong and what to do next, in the user's words.
- Success state confirms without celebrating banal actions.
5. **Inputs and forms.**
- Labels visible, not just placeholders.
- Validation runs at the right time (on blur, not on every keystroke unless the user is in a known-format field).
- Required fields marked.
- Field order matches the user's mental order, not the database order.
6. **Accessibility.**
- Sufficient color contrast (WCAG AA at minimum; AAA where reasonable).
- Focus order is logical for keyboard navigation.
- Interactive elements are reachable without a mouse.
- Critical information is not color-only (icons, text, position back it up).
- Touch targets at least 44×44 px on mobile.
7. **Consistency.**
- Tokens, components, and patterns match the rest of the product.
- "Borrowed" patterns from other products are intentional, not accidental drift.
8. **Copy.**
- Buttons are verbs that name the outcome ("Save changes" beats "Submit").
- Microcopy explains, does not decorate.
- Tone matches the product voice.
9. **Edge cases.**
- Long content (long names, many items, RTL languages).
- Tiny content (one item, zero items).
- Slow network and offline behavior.
- Permissions denied.
## Output format
Group findings by severity, then by category. Each finding is one issue and one suggested fix.
```md
## Design critique: <screen name>
### Must-fix (blocks ship)
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
### Should-fix (before broader rollout)
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
### Nice-to-fix (when there's room)
- **<category>:** <one-line issue>. **Try:** <one-line suggestion>.
### Strengths to keep
- <one-line thing the design got right>
```
Always include the "strengths to keep" section. It is not flattery — it is signal to the designer about what not to change in the next round.
## Anti-patterns
- "I would do it differently" without saying what or why. That is preference, not critique.
- Long critiques that bury must-fix items under nice-to-haves.
- Suggesting net-new features under the guise of a critique.
- Ignoring user context and grading on taste.
- Treating a critique as approval. State approval explicitly if asked; otherwise critique is feedback, not sign-off.
@@ -0,0 +1,285 @@
{
"schemaVersion": 1,
"packageName": "@paperclipai/skills-catalog",
"packageVersion": "0.3.1",
"generatedAt": "2026-05-28T03:02:49.579Z",
"skills": [
{
"id": "paperclipai:bundled:docs:doc-maintenance",
"key": "paperclipai/bundled/docs/doc-maintenance",
"kind": "bundled",
"category": "docs",
"slug": "doc-maintenance",
"name": "doc-maintenance",
"description": "Keep project docs aligned with recent code and feature changes — detect drift, update affected pages, and add release-relevant notes without rewriting unchanged sections.",
"path": "catalog/bundled/docs/doc-maintenance",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"engineer",
"product",
"devrel"
],
"requires": [],
"tags": [
"docs",
"documentation",
"release-notes"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4478,
"sha256": "fb0353386c5e5e5e13bcbb3233f044e3dccecf371f429d6328f26c26d7cb6169"
}
],
"contentHash": "sha256:2e02299210fd17c1fe1867b4ee8c144a11b6fe1fe481f83b8268cfbaaf10f9aa"
},
{
"id": "paperclipai:bundled:paperclip-operations:issue-triage",
"key": "paperclipai/bundled/paperclip-operations/issue-triage",
"kind": "bundled",
"category": "paperclip-operations",
"slug": "issue-triage",
"name": "issue-triage",
"description": "Triage Paperclip inbox issues that are stale, blocked, in-review, or assigned-but-not-progressing, and decide a single next action per issue (resume, reassign, unblock, escalate, or close).",
"path": "catalog/bundled/paperclip-operations/issue-triage",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"manager",
"ceo",
"engineer"
],
"requires": [],
"tags": [
"paperclip",
"triage",
"inbox",
"workflow"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4042,
"sha256": "df5bdc8bf5e017b7ba5f70a4b5323fad51d0c323278f386580f26cf43ad09160"
}
],
"contentHash": "sha256:88dc13560371fb364963782cb4f6eeb4090fcde92ee3774479428ed6b90e11c1"
},
{
"id": "paperclipai:bundled:paperclip-operations:task-planning",
"key": "paperclipai/bundled/paperclip-operations/task-planning",
"kind": "bundled",
"category": "paperclip-operations",
"slug": "task-planning",
"name": "task-planning",
"description": "Turn a Paperclip issue or request into a structured implementation plan with child task graph, blockers, owners, and acceptance criteria, then save it as the issue `plan` document.",
"path": "catalog/bundled/paperclip-operations/task-planning",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"manager",
"engineer",
"product"
],
"requires": [],
"tags": [
"paperclip",
"planning",
"issues",
"delegation"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4649,
"sha256": "2ff61e12dfaa4cf8cc548529fd176f55f1b1f5292ff9dd3eb2cb331417ab5e4e"
}
],
"contentHash": "sha256:4fb46a4bcefad4fd46fae48c433ee497112509a8e19fb8a7745ead44d219b498"
},
{
"id": "paperclipai:bundled:quality:qa-acceptance",
"key": "paperclipai/bundled/quality/qa-acceptance",
"kind": "bundled",
"category": "quality",
"slug": "qa-acceptance",
"name": "qa-acceptance",
"description": "Produce QA acceptance criteria and a manual validation plan for a feature change — golden path, edge cases, error states, performance limits, and explicit pass/fail evidence.",
"path": "catalog/bundled/quality/qa-acceptance",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"qa",
"engineer",
"product"
],
"requires": [],
"tags": [
"qa",
"acceptance",
"validation",
"testing"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 3861,
"sha256": "c631b437ab26d104af6cdb963d8f679a9341439041b3cb3ec8835f4ff551b378"
}
],
"contentHash": "sha256:32372dacaf62e93454b9855968c4eec96456ba78b509f450b3dfaa48e31ef356"
},
{
"id": "paperclipai:bundled:software-development:github-pr-workflow",
"key": "paperclipai/bundled/software-development/github-pr-workflow",
"kind": "bundled",
"category": "software-development",
"slug": "github-pr-workflow",
"name": "github-pr-workflow",
"description": "Prepare a GitHub pull request from a feature branch — branch hygiene, commit shape, title/body, verification notes, screenshots for UI work, and replies to review comments.",
"path": "catalog/bundled/software-development/github-pr-workflow",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"engineer"
],
"requires": [],
"tags": [
"github",
"pull-requests",
"code-review",
"release"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 3970,
"sha256": "f498ec4ebb1779dea37adeb1db8a8b22316282798e35ee02e2fc5ff627d7e261"
}
],
"contentHash": "sha256:90f278c89aa0711be150c1cd2456ca25620d02f36995b113ca9837d756a37f6c"
},
{
"id": "paperclipai:optional:browser:agent-browser",
"key": "paperclipai/optional/browser/agent-browser",
"kind": "optional",
"category": "browser",
"slug": "agent-browser",
"name": "agent-browser",
"description": "Drive a real browser to inspect or interact with a web page or app — navigate, take screenshots, read console and network, fill simple forms — for verification tasks, not unattended automation.",
"path": "catalog/optional/browser/agent-browser",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"qa",
"engineer",
"researcher"
],
"requires": [],
"tags": [
"browser",
"puppeteer",
"playwright",
"verification"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 5133,
"sha256": "362f7b9d02297782bc6f0c093f495b8a0304a75bcf4b42e5c280a42b1f757b7d"
}
],
"contentHash": "sha256:eabb2c9f7b5e1a27ebb1e05a711d61433a266478154cd671a685e99e67aadea2"
},
{
"id": "paperclipai:optional:content:release-announcement",
"key": "paperclipai/optional/content/release-announcement",
"kind": "optional",
"category": "content",
"slug": "release-announcement",
"name": "release-announcement",
"description": "Write a release announcement — changelog, blog post, in-app note, or social post — that leads with user impact, names the audience, and includes upgrade/migration steps without filler.",
"path": "catalog/optional/content/release-announcement",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"devrel",
"product",
"writer"
],
"requires": [],
"tags": [
"release",
"changelog",
"announcement",
"communication"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4416,
"sha256": "062810ac34e9edc89efa701fec2eee60f16949d1944cc2cae49803cb91e8cbf4"
}
],
"contentHash": "sha256:f22a9ed696e6614c6db2757a149f48b3295e81f78c27d065d9cb164cf4f8a9bd"
},
{
"id": "paperclipai:optional:product:design-critique",
"key": "paperclipai/optional/product/design-critique",
"kind": "optional",
"category": "product",
"slug": "design-critique",
"name": "design-critique",
"description": "Give a structured product design critique — user job clarity, hierarchy, affordance, error states, accessibility, and consistency — focused on what to change, in what order, and why.",
"path": "catalog/optional/product/design-critique",
"entrypoint": "SKILL.md",
"trustLevel": "markdown_only",
"compatibility": "compatible",
"defaultInstall": false,
"recommendedForRoles": [
"designer",
"product",
"engineer"
],
"requires": [],
"tags": [
"design",
"product",
"ux",
"review"
],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 4851,
"sha256": "022e619baf6cc25725946279cb8052d22af090dd6cd6dc8c20f17867f71a5d8e"
}
],
"contentHash": "sha256:429f94df398a0697042b5bbe4755b1ff1a230aa5f41d99118ad37493ac65d21c"
}
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@paperclipai/skills-catalog",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/skills-catalog"
},
"type": "module",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts",
"./catalog.json": "./generated/catalog.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
"./types": {
"types": "./dist/src/types.d.ts",
"import": "./dist/src/types.js"
},
"./catalog.json": "./dist/generated/catalog.json"
},
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
},
"files": [
"catalog",
"dist",
"generated"
],
"scripts": {
"build": "pnpm run build:manifest && tsc -p tsconfig.json",
"build:manifest": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/build-catalog-manifest.ts",
"clean": "rm -rf dist",
"test": "pnpm -w exec vitest run --root packages/skills-catalog --config vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"validate": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/validate-catalog.ts"
}
}
@@ -0,0 +1,15 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { writeCatalogManifest } from "../src/catalog-builder.js";
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = await writeCatalogManifest(packageDir);
if (result.errors.length > 0) {
for (const error of result.errors) {
console.error(`- ${error}`);
}
process.exitCode = 1;
} else {
console.log(`Wrote generated/catalog.json with ${result.manifest.skills.length} catalog skills.`);
}
@@ -0,0 +1,15 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { validateCatalog } from "../src/catalog-builder.js";
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = await validateCatalog(packageDir);
if (result.errors.length > 0) {
for (const error of result.errors) {
console.error(`- ${error}`);
}
process.exitCode = 1;
} else {
console.log(`Catalog manifest is valid with ${result.manifest.skills.length} catalog skills.`);
}
@@ -0,0 +1,165 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildCatalogManifest,
formatCatalogManifest,
validateCatalog,
} from "./catalog-builder.js";
const tempDirs: string[] = [];
describe("skills catalog manifest", () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it("builds stable manifest entries from catalog skill directories", async () => {
const packageDir = await createCatalogPackage();
await writeSkill(packageDir, "bundled", "software-development", "github-pr-workflow", {
frontmatter: [
"name: GitHub PR Workflow",
"description: Prepare pull requests and verification notes.",
"key: paperclipai/bundled/software-development/github-pr-workflow",
"recommendedForRoles:",
" - engineer",
"tags:",
" - github",
" - pull-requests",
],
files: {
"references/checklist.md": "# Checklist\n",
},
});
const result = await buildCatalogManifest({
packageDir,
generatedAt: "2026-05-26T00:00:00.000Z",
});
expect(result.errors).toEqual([]);
expect(result.manifest.skills).toHaveLength(1);
expect(result.manifest.skills[0]).toMatchObject({
id: "paperclipai:bundled:software-development:github-pr-workflow",
key: "paperclipai/bundled/software-development/github-pr-workflow",
kind: "bundled",
category: "software-development",
slug: "github-pr-workflow",
name: "GitHub PR Workflow",
trustLevel: "markdown_only",
compatibility: "compatible",
recommendedForRoles: ["engineer"],
tags: ["github", "pull-requests"],
});
expect(result.manifest.skills[0]!.files.map((file) => file.path)).toEqual([
"SKILL.md",
"references/checklist.md",
]);
expect(result.manifest.skills[0]!.contentHash).toMatch(/^sha256:[a-f0-9]{64}$/);
});
it("reports frontmatter, directory, uniqueness, and inventory errors together", async () => {
const packageDir = await createCatalogPackage();
await writeSkill(packageDir, "bundled", "Bad_Category", "duplicate", {
frontmatter: [
"name: Duplicate",
"key: paperclipai/bundled/software-development/other",
"recommendedForRoles: engineer",
],
});
await writeSkill(packageDir, "optional", "software-development", "duplicate", {
frontmatter: [
"name: Duplicate Optional",
"description: Optional duplicate slug.",
],
});
await fs.mkdir(path.join(packageDir, "catalog", "bundled", "software-development", "missing-skill"), {
recursive: true,
});
await fs.mkdir(path.join(packageDir, "catalog", "misc"), { recursive: true });
await fs.writeFile(path.join(packageDir, "catalog", "misc", "SKILL.md"), "# Misplaced\n", "utf8");
const result = await buildCatalogManifest({
packageDir,
generatedAt: "2026-05-26T00:00:00.000Z",
});
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining("catalog/misc/SKILL.md is not under catalog/<bundled|optional>/<category>/<slug>/SKILL.md"),
expect.stringContaining("catalog/bundled/software-development/missing-skill is missing SKILL.md"),
expect.stringContaining("has invalid category"),
expect.stringContaining("frontmatter must include description"),
expect.stringContaining("key must be paperclipai/bundled/Bad_Category/duplicate"),
expect.stringContaining("field recommendedForRoles must be an array of strings"),
expect.stringContaining("Duplicate catalog slug \"duplicate\""),
]),
);
});
it("detects stale generated manifests", async () => {
const packageDir = await createCatalogPackage();
await writeSkill(packageDir, "bundled", "software-development", "review", {
frontmatter: [
"name: Review",
"description: Review implementation work.",
],
});
await fs.mkdir(path.join(packageDir, "generated"), { recursive: true });
await fs.writeFile(
path.join(packageDir, "generated", "catalog.json"),
formatCatalogManifest({
schemaVersion: 1,
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.1",
generatedAt: "2026-05-26T00:00:00.000Z",
skills: [],
}),
"utf8",
);
const result = await validateCatalog(packageDir);
expect(result.errors).toContain(
"generated/catalog.json is stale. Run pnpm --filter @paperclipai/skills-catalog build:manifest.",
);
});
});
async function createCatalogPackage() {
const packageDir = await fs.mkdtemp(path.join(os.tmpdir(), "skills-catalog-"));
tempDirs.push(packageDir);
await fs.mkdir(path.join(packageDir, "catalog", "bundled"), { recursive: true });
await fs.mkdir(path.join(packageDir, "catalog", "optional"), { recursive: true });
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify({ version: "0.3.1" }),
"utf8",
);
return packageDir;
}
async function writeSkill(
packageDir: string,
kind: "bundled" | "optional",
category: string,
slug: string,
options: {
frontmatter: string[];
files?: Record<string, string>;
},
) {
const skillDir = path.join(packageDir, "catalog", kind, category, slug);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---\n${options.frontmatter.join("\n")}\n---\n\nUse this skill.\n`,
"utf8",
);
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.join(skillDir, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
}
@@ -0,0 +1,443 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
asBoolean,
asString,
asStringArray,
parseFrontmatterMarkdown,
} from "./frontmatter.js";
import type {
CatalogManifest,
CatalogSkill,
CatalogSkillFile,
CatalogSkillFileKind,
CatalogSkillKind,
CatalogTrustLevel,
} from "./types.js";
const CATALOG_PACKAGE_NAME = "@paperclipai/skills-catalog";
const CATALOG_SCHEMA_VERSION = 1;
const SKILL_ENTRYPOINT = "SKILL.md";
const MAX_CATALOG_FILE_BYTES = 1024 * 1024;
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const CATALOG_KINDS = new Set<CatalogSkillKind>(["bundled", "optional"]);
interface SkillCandidate {
kind: CatalogSkillKind;
category: string;
slug: string;
absolutePath: string;
}
interface BuildCatalogManifestOptions {
packageDir: string;
generatedAt?: string;
}
interface BuildCatalogManifestResult {
manifest: CatalogManifest;
errors: string[];
}
export function formatCatalogManifest(manifest: CatalogManifest): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
export async function buildExpectedCatalogManifest(
packageDir: string,
): Promise<BuildCatalogManifestResult> {
const existing = await readExistingManifest(packageDir);
const firstPass = await buildCatalogManifest({
packageDir,
generatedAt: existing?.generatedAt ?? new Date().toISOString(),
});
if (existing && sameManifestExceptGeneratedAt(existing, firstPass.manifest)) {
return firstPass;
}
return buildCatalogManifest({
packageDir,
generatedAt: new Date().toISOString(),
});
}
export async function buildCatalogManifest(
options: BuildCatalogManifestOptions,
): Promise<BuildCatalogManifestResult> {
const packageDir = path.resolve(options.packageDir);
const packageJson = await readPackageJson(packageDir);
const errors: string[] = [];
const candidates = await discoverSkillCandidates(packageDir, errors);
const skills: CatalogSkill[] = [];
collectCandidateUniquenessErrors(candidates, errors);
for (const candidate of candidates) {
const skill = await buildCatalogSkill(packageDir, candidate, errors);
if (skill) skills.push(skill);
}
skills.sort((a, b) => a.id.localeCompare(b.id));
collectUniquenessErrors(skills, errors);
return {
manifest: {
schemaVersion: CATALOG_SCHEMA_VERSION,
packageName: CATALOG_PACKAGE_NAME,
packageVersion: packageJson.version,
generatedAt: options.generatedAt ?? new Date().toISOString(),
skills,
},
errors,
};
}
export async function validateCatalog(packageDir: string): Promise<BuildCatalogManifestResult> {
const expected = await buildExpectedCatalogManifest(packageDir);
const generatedPath = path.join(packageDir, "generated", "catalog.json");
const errors = [...expected.errors];
let generatedText: string | null = null;
try {
generatedText = await fs.readFile(generatedPath, "utf8");
JSON.parse(generatedText);
} catch (error) {
errors.push(`generated/catalog.json is missing or invalid: ${errorMessage(error)}`);
}
if (generatedText !== null) {
const expectedText = formatCatalogManifest(expected.manifest);
if (generatedText !== expectedText) {
errors.push("generated/catalog.json is stale. Run pnpm --filter @paperclipai/skills-catalog build:manifest.");
}
}
return {
manifest: expected.manifest,
errors,
};
}
export async function writeCatalogManifest(packageDir: string) {
const result = await buildExpectedCatalogManifest(packageDir);
if (result.errors.length > 0) return result;
const generatedDir = path.join(packageDir, "generated");
await fs.mkdir(generatedDir, { recursive: true });
await fs.writeFile(path.join(generatedDir, "catalog.json"), formatCatalogManifest(result.manifest), "utf8");
return result;
}
async function readPackageJson(packageDir: string) {
const packageJsonPath = path.join(packageDir, "package.json");
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as { version?: unknown };
const version = asString(packageJson.version);
if (!version) throw new Error(`${packageJsonPath} must declare a package version.`);
return { version };
}
async function readExistingManifest(packageDir: string): Promise<CatalogManifest | null> {
try {
return JSON.parse(await fs.readFile(path.join(packageDir, "generated", "catalog.json"), "utf8")) as CatalogManifest;
} catch {
return null;
}
}
async function discoverSkillCandidates(packageDir: string, errors: string[]) {
const catalogDir = path.join(packageDir, "catalog");
const candidates: SkillCandidate[] = [];
if (!existsSync(catalogDir)) {
errors.push("catalog directory is missing.");
return candidates;
}
await collectMisplacedSkillFiles(catalogDir, errors);
for (const kind of ["bundled", "optional"] as const) {
const kindDir = path.join(catalogDir, kind);
if (!existsSync(kindDir)) continue;
for (const categoryEntry of await sortedDirEntries(kindDir)) {
if (!categoryEntry.isDirectory()) continue;
const category = categoryEntry.name;
const categoryDir = path.join(kindDir, category);
for (const slugEntry of await sortedDirEntries(categoryDir)) {
if (!slugEntry.isDirectory()) continue;
const slug = slugEntry.name;
const skillDir = path.join(categoryDir, slug);
if (!existsSync(path.join(skillDir, SKILL_ENTRYPOINT))) {
errors.push(`${relativePackagePath(packageDir, skillDir)} is missing SKILL.md.`);
continue;
}
candidates.push({ kind, category, slug, absolutePath: skillDir });
}
}
}
return candidates;
}
async function collectMisplacedSkillFiles(catalogDir: string, errors: string[]) {
async function visit(dir: string) {
for (const entry of await sortedDirEntries(dir)) {
const absolutePath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await visit(absolutePath);
continue;
}
if (entry.name !== SKILL_ENTRYPOINT) continue;
const relativePath = toPosixPath(path.relative(catalogDir, absolutePath));
const parts = relativePath.split("/");
const kind = parts[0];
if (parts.length !== 4 || !CATALOG_KINDS.has(kind as CatalogSkillKind)) {
errors.push(`catalog/${relativePath} is not under catalog/<bundled|optional>/<category>/<slug>/SKILL.md.`);
}
}
}
await visit(catalogDir);
}
async function buildCatalogSkill(
packageDir: string,
candidate: SkillCandidate,
errors: string[],
): Promise<CatalogSkill | null> {
const prefix = relativePackagePath(packageDir, candidate.absolutePath);
validateSlug("category", candidate.category, prefix, errors);
validateSlug("slug", candidate.slug, prefix, errors);
const id = `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`;
const key = `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`;
const skillMarkdownPath = path.join(candidate.absolutePath, SKILL_ENTRYPOINT);
const parsed = parseFrontmatterMarkdown(await fs.readFile(skillMarkdownPath, "utf8"));
if (!parsed.hasFrontmatter) {
errors.push(`${prefix}/SKILL.md must start with YAML frontmatter.`);
}
const name = asString(parsed.frontmatter.name);
if (!name) errors.push(`${prefix}/SKILL.md frontmatter must include name.`);
const description = asString(parsed.frontmatter.description);
if (!description) errors.push(`${prefix}/SKILL.md frontmatter must include description.`);
const explicitKey = asString(parsed.frontmatter.key);
if (explicitKey && explicitKey !== key) {
errors.push(`${prefix}/SKILL.md key must be ${key}.`);
}
const explicitSlug = asString(parsed.frontmatter.slug);
if (explicitSlug && explicitSlug !== candidate.slug) {
errors.push(`${prefix}/SKILL.md slug must be ${candidate.slug}.`);
}
const defaultInstall = asBoolean(parsed.frontmatter.defaultInstall) ?? false;
const recommendedForRoles = readStringArrayField(parsed.frontmatter.recommendedForRoles, "recommendedForRoles", prefix, errors);
const requires = readStringArrayField(parsed.frontmatter.requires, "requires", prefix, errors);
const tags = readStringArrayField(parsed.frontmatter.tags, "tags", prefix, errors);
const files = await collectSkillFiles(packageDir, candidate.absolutePath, prefix, errors);
if (!name || !description) return null;
return {
id,
key,
kind: candidate.kind,
category: candidate.category,
slug: candidate.slug,
name,
description,
path: toPosixPath(path.relative(packageDir, candidate.absolutePath)),
entrypoint: SKILL_ENTRYPOINT,
trustLevel: deriveTrustLevel(files),
compatibility: "compatible",
defaultInstall,
recommendedForRoles,
requires,
tags,
files,
contentHash: buildContentHash(files),
};
}
async function collectSkillFiles(
packageDir: string,
skillDir: string,
prefix: string,
errors: string[],
): Promise<CatalogSkillFile[]> {
const files: CatalogSkillFile[] = [];
const skillRoot = await fs.realpath(skillDir);
async function visit(dir: string) {
for (const entry of await sortedDirEntries(dir)) {
const absolutePath = path.join(dir, entry.name);
const lstat = await fs.lstat(absolutePath);
let stat = lstat;
let realPath = absolutePath;
if (lstat.isSymbolicLink()) {
try {
realPath = await fs.realpath(absolutePath);
stat = await fs.stat(absolutePath);
} catch {
errors.push(`${relativePackagePath(packageDir, absolutePath)} is a broken symlink.`);
continue;
}
if (!isPathInside(skillRoot, realPath)) {
errors.push(`${relativePackagePath(packageDir, absolutePath)} points outside its skill directory.`);
continue;
}
if (stat.isDirectory()) {
errors.push(`${relativePackagePath(packageDir, absolutePath)} is a directory symlink; copy files into the skill directory instead.`);
continue;
}
}
if (stat.isDirectory()) {
await visit(absolutePath);
continue;
}
if (!stat.isFile()) continue;
const relativePath = toPosixPath(path.relative(skillDir, absolutePath));
if (path.isAbsolute(relativePath) || relativePath.split("/").includes("..")) {
errors.push(`${prefix}/${relativePath} has an invalid inventory path.`);
continue;
}
if (stat.size > MAX_CATALOG_FILE_BYTES) {
errors.push(`${prefix}/${relativePath} exceeds ${MAX_CATALOG_FILE_BYTES} bytes.`);
}
const contents = await fs.readFile(absolutePath);
files.push({
path: relativePath,
kind: classifyCatalogFile(relativePath),
sizeBytes: stat.size,
sha256: sha256(contents),
});
}
}
await visit(skillDir);
files.sort((a, b) => {
if (a.path === SKILL_ENTRYPOINT) return -1;
if (b.path === SKILL_ENTRYPOINT) return 1;
return a.path.localeCompare(b.path);
});
if (!files.some((file) => file.path === SKILL_ENTRYPOINT && file.kind === "skill")) {
errors.push(`${prefix} inventory does not contain SKILL.md.`);
}
return files;
}
function readStringArrayField(
value: unknown,
field: string,
prefix: string,
errors: string[],
) {
const parsed = asStringArray(value);
if (!parsed) {
errors.push(`${prefix}/SKILL.md frontmatter field ${field} must be an array of strings.`);
return [];
}
return parsed;
}
function classifyCatalogFile(relativePath: string): CatalogSkillFileKind {
if (relativePath === SKILL_ENTRYPOINT) return "skill";
if (relativePath.startsWith("references/")) return "reference";
if (relativePath.startsWith("scripts/")) return "script";
if (relativePath.startsWith("assets/")) return "asset";
if (relativePath.endsWith(".md") || relativePath.endsWith(".mdx")) return "markdown";
return "other";
}
function deriveTrustLevel(files: CatalogSkillFile[]): CatalogTrustLevel {
if (files.some((file) => file.kind === "script")) return "scripts_executables";
if (files.some((file) => file.kind === "asset" || file.kind === "other")) return "assets";
return "markdown_only";
}
function buildContentHash(files: CatalogSkillFile[]) {
const hashInput = files.map((file) => ({
path: file.path,
sha256: file.sha256,
}));
return `sha256:${sha256(Buffer.from(JSON.stringify(hashInput)))}`;
}
function collectUniquenessErrors(skills: CatalogSkill[], errors: string[]) {
collectDuplicateErrors(skills, "id", errors);
collectDuplicateErrors(skills, "key", errors);
collectDuplicateErrors(skills, "slug", errors);
}
function collectCandidateUniquenessErrors(candidates: SkillCandidate[], errors: string[]) {
const projected = candidates.map((candidate) => ({
id: `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`,
key: `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`,
slug: candidate.slug,
path: toPosixPath(path.join("catalog", candidate.kind, candidate.category, candidate.slug)),
})) as CatalogSkill[];
collectUniquenessErrors(projected, errors);
}
function collectDuplicateErrors(fieldSkills: CatalogSkill[], field: "id" | "key" | "slug", errors: string[]) {
const seen = new Map<string, string>();
for (const skill of fieldSkills) {
const value = skill[field];
const first = seen.get(value);
if (first) {
errors.push(`Duplicate catalog ${field} "${value}" in ${first} and ${skill.path}.`);
continue;
}
seen.set(value, skill.path);
}
}
function validateSlug(label: string, value: string, prefix: string, errors: string[]) {
if (!SLUG_PATTERN.test(value)) {
errors.push(`${prefix} has invalid ${label} "${value}"; use lowercase URL slugs.`);
}
}
async function sortedDirEntries(dir: string) {
return (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
}
function sameManifestExceptGeneratedAt(a: CatalogManifest, b: CatalogManifest) {
return JSON.stringify({ ...a, generatedAt: "" }) === JSON.stringify({ ...b, generatedAt: "" });
}
function sha256(contents: Buffer) {
return createHash("sha256").update(contents).digest("hex");
}
function relativePackagePath(packageDir: string, absolutePath: string) {
return toPosixPath(path.relative(packageDir, absolutePath));
}
function toPosixPath(input: string) {
return input.split(path.sep).join("/");
}
function isPathInside(parent: string, child: string) {
const relativePath = path.relative(parent, child);
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+154
View File
@@ -0,0 +1,154 @@
export interface MarkdownDoc {
frontmatter: Record<string, unknown>;
body: string;
hasFrontmatter: boolean;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function asBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
export function asStringArray(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value)) return null;
const out: string[] = [];
for (const item of value) {
const text = asString(item);
if (!text) return null;
out.push(text);
}
return out;
}
export function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
const normalized = raw.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const frontmatterRaw = normalized.slice(4, closing).trim();
const body = normalized.slice(closing + 5).trim();
return {
frontmatter: parseYamlFrontmatter(frontmatterRaw),
body,
hasFrontmatter: true,
};
}
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
const prepared = prepareYamlLines(raw);
if (prepared.length === 0) return {};
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
return isPlainRecord(parsed.value) ? parsed.value : {};
}
function prepareYamlLines(raw: string) {
return raw
.split("\n")
.map((line) => ({
indent: line.match(/^ */)?.[0].length ?? 0,
content: line.trim(),
}))
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
}
function parseYamlBlock(
lines: Array<{ indent: number; content: string }>,
startIndex: number,
indentLevel: number,
): { value: unknown; nextIndex: number } {
let index = startIndex;
if (index >= lines.length || lines[index]!.indent < indentLevel) {
return { value: {}, nextIndex: index };
}
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
if (isArray) {
const values: unknown[] = [];
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
const remainder = line.content.slice(1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
values.push(nested.value);
index = nested.nextIndex;
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
}
const record: Record<string, unknown> = {};
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel) {
index += 1;
continue;
}
const separatorIndex = line.content.indexOf(":");
if (separatorIndex <= 0) {
index += 1;
continue;
}
const key = line.content.slice(0, separatorIndex).trim();
const remainder = line.content.slice(separatorIndex + 1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
record[key] = nested.value;
index = nested.nextIndex;
continue;
}
record[key] = parseYamlScalar(remainder);
}
return { value: record, nextIndex: index };
}
function parseYamlScalar(rawValue: string): unknown {
const trimmed = rawValue.trim();
if (trimmed === "") return "";
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "[]") return [];
if (trimmed === "{}") return {};
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (
trimmed.startsWith("\"") ||
trimmed.startsWith("[") ||
trimmed.startsWith("{")
) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
+37
View File
@@ -0,0 +1,37 @@
import catalogManifestJson from "../generated/catalog.json" with { type: "json" };
import type { CatalogManifest, CatalogSkill } from "./types.js";
export type {
CatalogCompatibility,
CatalogManifest,
CatalogSkill,
CatalogSkillFile,
CatalogSkillFileKind,
CatalogSkillKind,
CatalogTrustLevel,
CatalogValidationResult,
} from "./types.js";
export const catalogManifest = catalogManifestJson as CatalogManifest;
export const catalogSkills: CatalogSkill[] = catalogManifest.skills;
const skillsById = new Map(catalogSkills.map((skill) => [skill.id, skill]));
const skillsByKey = new Map(catalogSkills.map((skill) => [skill.key, skill]));
export function getCatalogSkill(id: string): CatalogSkill | null {
return skillsById.get(id) ?? null;
}
export function resolveCatalogSkillRef(ref: string): CatalogSkill | null {
const normalized = ref.trim();
if (normalized.length === 0) return null;
const exactMatch = skillsById.get(normalized) ?? skillsByKey.get(normalized);
if (exactMatch) return exactMatch;
const slugMatches = catalogSkills.filter((skill) => skill.slug === normalized);
if (slugMatches.length === 1) return slugMatches[0]!;
return null;
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { catalogManifest, catalogSkills, resolveCatalogSkillRef } from "./index.js";
import type { CatalogSkill } from "./types.js";
const EXPECTED_BUNDLED_KEYS = [
"paperclipai/bundled/docs/doc-maintenance",
"paperclipai/bundled/paperclip-operations/issue-triage",
"paperclipai/bundled/paperclip-operations/task-planning",
"paperclipai/bundled/quality/qa-acceptance",
"paperclipai/bundled/software-development/github-pr-workflow",
];
const EXPECTED_OPTIONAL_KEYS = [
"paperclipai/optional/browser/agent-browser",
"paperclipai/optional/content/release-announcement",
"paperclipai/optional/product/design-critique",
];
describe("shipped skills catalog", () => {
it("ships the expected bundled and optional skill set", () => {
const bundledKeys = catalogSkills
.filter((skill) => skill.kind === "bundled")
.map((skill) => skill.key)
.sort();
const optionalKeys = catalogSkills
.filter((skill) => skill.kind === "optional")
.map((skill) => skill.key)
.sort();
expect(bundledKeys).toEqual(EXPECTED_BUNDLED_KEYS);
expect(optionalKeys).toEqual(EXPECTED_OPTIONAL_KEYS);
});
it("keeps every shipped skill markdown-only until a script-bearing skill clears security review", () => {
const scriptBearing = catalogSkills.filter((skill) => skill.trustLevel !== "markdown_only");
expect(scriptBearing, formatViolations("script-bearing skills require security review", scriptBearing)).toEqual([]);
});
it("populates browse/search-relevant fields for every shipped skill", () => {
const issues: string[] = [];
for (const skill of catalogSkills) {
if (skill.compatibility !== "compatible") {
issues.push(`${skill.key} compatibility=${skill.compatibility}`);
}
if (!skill.description || skill.description.length < 40) {
issues.push(`${skill.key} description must be at least 40 characters for catalog browse/search`);
}
if (skill.recommendedForRoles.length === 0) {
issues.push(`${skill.key} must list recommendedForRoles`);
}
if (skill.tags.length === 0) {
issues.push(`${skill.key} must list tags`);
}
}
expect(issues).toEqual([]);
});
it("uses canonical paperclipai keys derived from kind/category/slug", () => {
const violations: string[] = [];
for (const skill of catalogSkills) {
const expectedKey = `paperclipai/${skill.kind}/${skill.category}/${skill.slug}`;
const expectedId = `paperclipai:${skill.kind}:${skill.category}:${skill.slug}`;
if (skill.key !== expectedKey) violations.push(`${skill.key} should be ${expectedKey}`);
if (skill.id !== expectedId) violations.push(`${skill.id} should be ${expectedId}`);
}
expect(violations).toEqual([]);
});
it("exposes a stable manifest header for downstream consumers", () => {
expect(catalogManifest.schemaVersion).toBe(1);
expect(catalogManifest.packageName).toBe("@paperclipai/skills-catalog");
expect(catalogSkills.length).toBe(EXPECTED_BUNDLED_KEYS.length + EXPECTED_OPTIONAL_KEYS.length);
});
it("resolves shipped skills by id, key, and unique slug", () => {
const sample = catalogSkills.find((skill) => skill.key === "paperclipai/bundled/software-development/github-pr-workflow");
expect(sample, "expected github-pr-workflow to ship in the bundled catalog").toBeDefined();
if (!sample) return;
expect(resolveCatalogSkillRef(sample.id)).toMatchObject({ key: sample.key });
expect(resolveCatalogSkillRef(sample.key)).toMatchObject({ key: sample.key });
expect(resolveCatalogSkillRef(sample.slug)).toMatchObject({ key: sample.key });
});
});
function formatViolations(label: string, skills: CatalogSkill[]) {
if (skills.length === 0) return label;
const detail = skills.map((skill) => `${skill.key} (${skill.trustLevel})`).join(", ");
return `${label}: ${detail}`;
}
+48
View File
@@ -0,0 +1,48 @@
export type CatalogSkillKind = "bundled" | "optional";
export type CatalogTrustLevel = "markdown_only" | "assets" | "scripts_executables";
export type CatalogCompatibility = "compatible" | "unknown" | "invalid";
export type CatalogSkillFileKind = "skill" | "markdown" | "reference" | "script" | "asset" | "other";
export interface CatalogSkillFile {
path: string;
kind: CatalogSkillFileKind;
sizeBytes: number;
sha256: string;
}
export interface CatalogSkill {
id: string;
key: string;
kind: CatalogSkillKind;
category: string;
slug: string;
name: string;
description: string;
path: string;
entrypoint: "SKILL.md";
trustLevel: CatalogTrustLevel;
compatibility: CatalogCompatibility;
defaultInstall: boolean;
recommendedForRoles: string[];
requires: string[];
tags: string[];
files: CatalogSkillFile[];
contentHash: string;
}
export interface CatalogManifest {
schemaVersion: 1;
packageName: "@paperclipai/skills-catalog";
packageVersion: string;
generatedAt: string;
skills: CatalogSkill[];
}
export interface CatalogValidationResult {
valid: boolean;
errors: string[];
manifest: CatalogManifest;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["generated/**/*.json", "scripts/**/*.ts", "src/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+543
View File
@@ -622,6 +622,8 @@ importers:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3
packages/skills-catalog: {}
server: server:
dependencies: dependencies:
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
@@ -702,9 +704,15 @@ importers:
hermes-paperclip-adapter: hermes-paperclip-adapter:
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0
isomorphic-git:
specifier: ^1.38.0
version: 1.38.3
jsdom: jsdom:
specifier: ^28.1.0 specifier: ^28.1.0
version: 28.1.0(@noble/hashes@2.0.1) version: 28.1.0(@noble/hashes@2.0.1)
memfs:
specifier: ^4.57.2
version: 4.57.3(tslib@2.8.1)
multer: multer:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
@@ -2315,6 +2323,126 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsonjoy.com/base64@1.1.2':
resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/base64@17.67.0':
resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/buffers@1.2.1':
resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/buffers@17.67.0':
resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/codegen@1.0.0':
resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/codegen@17.67.0':
resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-core@4.57.3':
resolution: {integrity: sha512-IvO50vkGydDZwS1e9rz/JXEtCCt9XvqxoGI6FlrVIvVm4/HpygMKW4ETtREWtMTsN5CLJ9FR6GuCduoQPZLBiw==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-fsa@4.57.3':
resolution: {integrity: sha512-JlIDGUWPl7Y6zl+/ISnZuh8z2aMr/xoR66D18zlaVAuL192CvlNJEzOlzp27x4P52HRtDnCSOk6f59vTsmp5vw==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-node-builtins@4.57.3':
resolution: {integrity: sha512-JAI3PqNuY8BR7ovy4h0bADLrqJLIcUauONNZfyTxUnj3Wf3tpTYe39eJ6z7FzYyA+tdMt33VpiQQUikGr3QOBw==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-node-to-fsa@4.57.3':
resolution: {integrity: sha512-uZGxyC0zDmcmW5bfHd4YivAZ54BLlbF9G0K5rBaksI/tZdJSGM7/AC+1TY7yvFu0Wc6gUHR7mFwf6SbQ3J1BTQ==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-node-utils@4.57.3':
resolution: {integrity: sha512-quCil8AvfcOxob4pn0drGdcQWpkPVgkt9q1+EjeyXXT40/L3l5lvYrr6hR8LmHu0eg+DNNaUwqjLT6Hr7V4sdQ==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-node@4.57.3':
resolution: {integrity: sha512-089gZoKvbeOsT2jeBaVKSz91oFXQWFG7a62sMY6gVMHnoWbyGzTb6OVUP/V7G3wLQLJ555BEsHt8SD1nj1dgaQ==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-print@4.57.3':
resolution: {integrity: sha512-ITwaLZpGIqD9jHndwMvDFZDIvbVzGRsJZDQ5HKln0vyMculu1c1nb7zbEBgY8BVSBZ9S2xO138OWIBGeRsrF3Q==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/fs-snapshot@4.57.3':
resolution: {integrity: sha512-wdNaG2DxCtvj9lKldAnEV3ycYPEpk+p2cP2lHD1qdxkoQGlWUtQverqvG9KZSkm6BHFha4PP6XRZbpARNfHRxA==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/json-pack@1.21.0':
resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/json-pack@17.67.0':
resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/json-pointer@1.0.2':
resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/json-pointer@17.67.0':
resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/util@1.9.0':
resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@jsonjoy.com/util@17.67.0':
resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==}
engines: {node: '>=10.0'}
peerDependencies:
tslib: '2'
'@lexical/clipboard@0.35.0': '@lexical/clipboard@0.35.0':
resolution: {integrity: sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==} resolution: {integrity: sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==}
@@ -4170,6 +4298,10 @@ packages:
abbrev@1.1.1: abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} 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: accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -4277,6 +4409,9 @@ packages:
resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
asynckit@0.4.0: asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -4284,6 +4419,10 @@ packages:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'} 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: axe-core@4.11.3:
resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -4485,6 +4624,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} 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: call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4540,6 +4683,9 @@ packages:
classnames@2.5.1: classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} 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: clean-set@1.1.2:
resolution: {integrity: sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==} resolution: {integrity: sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==}
@@ -4653,6 +4799,11 @@ packages:
cose-base@2.2.0: cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} 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: crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@@ -4893,6 +5044,10 @@ packages:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'} 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: define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -4936,6 +5091,9 @@ packages:
dezalgo@1.0.4: dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
diff3@0.0.3:
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
diff@5.2.2: diff@5.2.2:
resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
@@ -5209,9 +5367,17 @@ packages:
event-emitter@0.3.5: event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} 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: events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.6: eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -5291,6 +5457,10 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'} engines: {node: '>= 18.0.0'}
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
form-data@4.0.5: form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -5365,6 +5535,12 @@ packages:
github-from-package@0.0.0: github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} 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: glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -5383,6 +5559,9 @@ packages:
hachure-fill@0.5.2: hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} 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: has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5457,6 +5636,10 @@ packages:
humanize-ms@1.2.1: humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
hyperdyperid@1.2.0:
resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
engines: {node: '>=10.18'}
i18next@26.2.0: i18next@26.2.0:
resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==} resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==}
peerDependencies: peerDependencies:
@@ -5476,6 +5659,10 @@ packages:
ieee754@1.2.1: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 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: imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
@@ -5529,6 +5716,10 @@ packages:
is-alphanumerical@2.0.1: is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} 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: is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5573,13 +5764,25 @@ packages:
is-promise@4.0.0: is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} 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: is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'} engines: {node: '>=16'}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-git@1.38.3:
resolution: {integrity: sha512-rOpt0yIW9HD1o6mA4w2f4XP5Lj42QnccbFbhzkby6L+t9c2klQxf9seqyrw5x8JcWWnbuPuN7v7YJ7yvHj9OPA==}
engines: {node: '>=14.17'}
hasBin: true
isomorphic.js@0.2.5: isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
@@ -5859,6 +6062,11 @@ packages:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
memfs@4.57.3:
resolution: {integrity: sha512-dlvqataP1zUOlfj6pv9wgCSC5pRIooNntXgdLfR7FWlcKi1p8fMfJADtHp/+8Dhu5JFvMHNh7L0QVcuaaBKqqA==}
peerDependencies:
tslib: '2'
merge-descriptors@2.0.0: merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -6023,6 +6231,9 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minimisted@2.0.1:
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
minipass-collect@1.0.2: minipass-collect@1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -6177,6 +6388,9 @@ packages:
package-manager-detector@1.6.0: package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parse-entities@4.0.2: parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -6259,6 +6473,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pino-abstract-transport@2.0.0: pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
@@ -6302,6 +6520,10 @@ packages:
points-on-path@0.2.1: points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} 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: postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -6351,6 +6573,10 @@ packages:
process-warning@5.0.0: process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} 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: promise-inflight@1.0.1:
resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
peerDependencies: peerDependencies:
@@ -6534,6 +6760,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} 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: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@@ -6664,9 +6894,18 @@ packages:
set-cookie-parser@2.7.2: set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} 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: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} 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: sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -6891,6 +7130,12 @@ packages:
text-decoder@1.2.7: text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} 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: thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
@@ -6930,6 +7175,10 @@ packages:
resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==}
hasBin: true hasBin: true
to-buffer@1.2.2:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'}
toidentifier@1.0.1: toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -6942,6 +7191,12 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'} 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: trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -6981,6 +7236,10 @@ packages:
type@2.7.3: type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} 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: typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
@@ -7291,6 +7550,10 @@ packages:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
which-typed-array@1.1.21:
resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==}
engines: {node: '>= 0.4'}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -9027,6 +9290,133 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@jsonjoy.com/base64@1.1.2(tslib@2.8.1)':
dependencies:
tslib: 2.8.1
'@jsonjoy.com/base64@17.67.0(tslib@2.8.1)':
dependencies:
tslib: 2.8.1
'@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)':
dependencies:
tslib: 2.8.1
'@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)':
dependencies:
tslib: 2.8.1
'@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)':
dependencies:
tslib: 2.8.1
'@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)':
dependencies:
tslib: 2.8.1
'@jsonjoy.com/fs-core@4.57.3(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
thingies: 2.6.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/fs-fsa@4.57.3(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/fs-core': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
thingies: 2.6.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/fs-node-builtins@4.57.3(tslib@2.8.1)':
dependencies:
tslib: 2.8.1
'@jsonjoy.com/fs-node-to-fsa@4.57.3(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/fs-fsa': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/fs-node-utils@4.57.3(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/fs-node@4.57.3(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/fs-core': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-print': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-snapshot': 4.57.3(tslib@2.8.1)
glob-to-regex.js: 1.2.0(tslib@2.8.1)
thingies: 2.6.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/fs-print@4.57.3(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
tree-dump: 1.1.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/fs-snapshot@4.57.3(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1)
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1)
'@jsonjoy.com/util': 17.67.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/base64': 1.1.2(tslib@2.8.1)
'@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1)
'@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1)
'@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1)
'@jsonjoy.com/util': 1.9.0(tslib@2.8.1)
hyperdyperid: 1.2.0
thingies: 2.6.0(tslib@2.8.1)
tree-dump: 1.1.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/base64': 17.67.0(tslib@2.8.1)
'@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1)
'@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1)
'@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1)
'@jsonjoy.com/util': 17.67.0(tslib@2.8.1)
hyperdyperid: 1.2.0
thingies: 2.6.0(tslib@2.8.1)
tree-dump: 1.1.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1)
'@jsonjoy.com/util': 1.9.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/util': 17.67.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/util@1.9.0(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1)
'@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1)
tslib: 2.8.1
'@jsonjoy.com/util@17.67.0(tslib@2.8.1)':
dependencies:
'@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1)
'@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1)
tslib: 2.8.1
'@lexical/clipboard@0.35.0': '@lexical/clipboard@0.35.0':
dependencies: dependencies:
'@lexical/html': 0.35.0 '@lexical/html': 0.35.0
@@ -11264,6 +11654,10 @@ snapshots:
abbrev@1.1.1: abbrev@1.1.1:
optional: true optional: true
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@2.0.0: accepts@2.0.0:
dependencies: dependencies:
mime-types: 3.0.2 mime-types: 3.0.2
@@ -11369,10 +11763,16 @@ snapshots:
async-exit-hook@2.0.1: {} async-exit-hook@2.0.1: {}
async-lock@1.4.1: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
atomic-sleep@1.0.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: {} axe-core@4.11.3: {}
b4a@1.8.1: {} b4a@1.8.1: {}
@@ -11552,6 +11952,13 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
function-bind: 1.1.2 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: call-bound@1.0.4:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -11607,6 +12014,8 @@ snapshots:
classnames@2.5.1: {} classnames@2.5.1: {}
clean-git-ref@2.0.1: {}
clean-set@1.1.2: {} clean-set@1.1.2: {}
clean-stack@2.2.0: clean-stack@2.2.0:
@@ -11708,6 +12117,8 @@ snapshots:
dependencies: dependencies:
layout-base: 2.0.1 layout-base: 2.0.1
crc-32@1.2.2: {}
crelt@1.0.6: {} crelt@1.0.6: {}
cross-env@10.1.0: cross-env@10.1.0:
@@ -11966,6 +12377,12 @@ snapshots:
bundle-name: 4.1.0 bundle-name: 4.1.0
default-browser-id: 5.0.1 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: {} define-lazy-prop@3.0.0: {}
defu@6.1.4: {} defu@6.1.4: {}
@@ -12000,6 +12417,8 @@ snapshots:
asap: 2.0.6 asap: 2.0.6
wrappy: 1.0.2 wrappy: 1.0.2
diff3@0.0.3: {}
diff@5.2.2: {} diff@5.2.2: {}
diff@8.0.3: {} diff@8.0.3: {}
@@ -12265,12 +12684,16 @@ snapshots:
d: 1.0.2 d: 1.0.2
es5-ext: 0.10.64 es5-ext: 0.10.64
event-target-shim@5.0.1: {}
events-universal@1.0.1: events-universal@1.0.1:
dependencies: dependencies:
bare-events: 2.8.2 bare-events: 2.8.2
transitivePeerDependencies: transitivePeerDependencies:
- bare-abort-controller - bare-abort-controller
events@3.3.0: {}
eventsource-parser@3.0.6: {} eventsource-parser@3.0.6: {}
eventsource@3.0.7: eventsource@3.0.7:
@@ -12370,6 +12793,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
form-data@4.0.5: form-data@4.0.5:
dependencies: dependencies:
asynckit: 0.4.0 asynckit: 0.4.0
@@ -12449,6 +12876,10 @@ snapshots:
github-from-package@0.0.0: {} 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: glob@13.0.6:
dependencies: dependencies:
minimatch: 10.2.5 minimatch: 10.2.5
@@ -12471,6 +12902,10 @@ snapshots:
hachure-fill@0.5.2: {} hachure-fill@0.5.2: {}
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
has-tostringtag@1.0.2: has-tostringtag@1.0.2:
@@ -12592,6 +13027,8 @@ snapshots:
ms: 2.1.3 ms: 2.1.3
optional: true optional: true
hyperdyperid@1.2.0: {}
i18next@26.2.0(typescript@5.9.3): i18next@26.2.0(typescript@5.9.3):
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -12606,6 +13043,8 @@ snapshots:
ieee754@1.2.1: {} ieee754@1.2.1: {}
ignore@5.3.2: {}
imurmurhash@0.1.4: imurmurhash@0.1.4:
optional: true optional: true
@@ -12646,6 +13085,8 @@ snapshots:
is-alphabetical: 2.0.1 is-alphabetical: 2.0.1
is-decimal: 2.0.1 is-decimal: 2.0.1
is-callable@1.2.7: {}
is-core-module@2.16.1: is-core-module@2.16.1:
dependencies: dependencies:
hasown: 2.0.2 hasown: 2.0.2
@@ -12676,12 +13117,32 @@ snapshots:
is-promise@4.0.0: {} is-promise@4.0.0: {}
is-typed-array@1.1.15:
dependencies:
which-typed-array: 1.1.21
is-wsl@3.1.1: is-wsl@3.1.1:
dependencies: dependencies:
is-inside-container: 1.0.0 is-inside-container: 1.0.0
isarray@2.0.5: {}
isexe@2.0.0: {} isexe@2.0.0: {}
isomorphic-git@1.38.3:
dependencies:
async-lock: 1.4.1
clean-git-ref: 2.0.1
crc-32: 1.2.2
diff3: 0.0.3
ignore: 5.3.2
minimisted: 2.0.1
pako: 1.0.11
pify: 4.0.1
readable-stream: 4.7.0
sha.js: 2.4.12
simple-get: 4.0.1
isomorphic.js@0.2.5: {} isomorphic.js@0.2.5: {}
jiti@2.6.1: {} jiti@2.6.1: {}
@@ -13075,6 +13536,23 @@ snapshots:
media-typer@1.1.0: {} media-typer@1.1.0: {}
memfs@4.57.3(tslib@2.8.1):
dependencies:
'@jsonjoy.com/fs-core': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-fsa': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-builtins': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-to-fsa': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-node-utils': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-print': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/fs-snapshot': 4.57.3(tslib@2.8.1)
'@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1)
'@jsonjoy.com/util': 1.9.0(tslib@2.8.1)
glob-to-regex.js: 1.2.0(tslib@2.8.1)
thingies: 2.6.0(tslib@2.8.1)
tree-dump: 1.1.0(tslib@2.8.1)
tslib: 2.8.1
merge-descriptors@2.0.0: {} merge-descriptors@2.0.0: {}
mermaid@11.12.3: mermaid@11.12.3:
@@ -13421,6 +13899,10 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
minimisted@2.0.1:
dependencies:
minimist: 1.2.8
minipass-collect@1.0.2: minipass-collect@1.0.2:
dependencies: dependencies:
minipass: 3.3.6 minipass: 3.3.6
@@ -13585,6 +14067,8 @@ snapshots:
package-manager-detector@1.6.0: {} package-manager-detector@1.6.0: {}
pako@1.0.11: {}
parse-entities@4.0.2: parse-entities@4.0.2:
dependencies: dependencies:
'@types/unist': 2.0.11 '@types/unist': 2.0.11
@@ -13664,6 +14148,8 @@ snapshots:
picomatch@4.0.3: {} picomatch@4.0.3: {}
pify@4.0.1: {}
pino-abstract-transport@2.0.0: pino-abstract-transport@2.0.0:
dependencies: dependencies:
split2: 4.2.0 split2: 4.2.0
@@ -13734,6 +14220,8 @@ snapshots:
path-data-parser: 0.1.0 path-data-parser: 0.1.0
points-on-curve: 0.2.0 points-on-curve: 0.2.0
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@6.0.10: postcss-selector-parser@6.0.10:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@@ -13784,6 +14272,8 @@ snapshots:
process-warning@5.0.0: {} process-warning@5.0.0: {}
process@0.11.10: {}
promise-inflight@1.0.1: promise-inflight@1.0.1:
optional: true optional: true
@@ -14028,6 +14518,14 @@ snapshots:
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 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: {} readdirp@4.1.2: {}
real-require@0.2.0: {} real-require@0.2.0: {}
@@ -14215,8 +14713,23 @@ snapshots:
set-cookie-parser@2.7.2: {} 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: {} 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: sharp@0.34.5:
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
@@ -14550,6 +15063,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- react-native-b4a - react-native-b4a
thingies@2.6.0(tslib@2.8.1):
dependencies:
tslib: 2.8.1
thread-stream@3.1.0: thread-stream@3.1.0:
dependencies: dependencies:
real-require: 0.2.0 real-require: 0.2.0
@@ -14579,6 +15096,12 @@ snapshots:
dependencies: dependencies:
tldts-core: 7.0.26 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: {} toidentifier@1.0.1: {}
tough-cookie@6.0.1: tough-cookie@6.0.1:
@@ -14589,6 +15112,10 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
tree-dump@1.1.0(tslib@2.8.1):
dependencies:
tslib: 2.8.1
trim-lines@3.0.1: {} trim-lines@3.0.1: {}
trough@2.2.0: {} trough@2.2.0: {}
@@ -14629,6 +15156,12 @@ snapshots:
type@2.7.3: {} 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: {} typedarray@0.0.6: {}
typescript@5.9.3: {} typescript@5.9.3: {}
@@ -15005,6 +15538,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@noble/hashes' - '@noble/hashes'
which-typed-array@1.1.21:
dependencies:
available-typed-arrays: 1.0.7
call-bind: 1.0.9
call-bound: 1.0.4
for-each: 0.3.5
get-proto: 1.0.1
gopd: 1.2.0
has-tostringtag: 1.0.2
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
+5
View File
@@ -104,6 +104,11 @@
"name": "@paperclipai/plugin-daytona", "name": "@paperclipai/plugin-daytona",
"publishFromCi": true "publishFromCi": true
}, },
{
"dir": "packages/plugins/sandbox-providers/kubernetes",
"name": "@paperclipai/plugin-kubernetes",
"publishFromCi": false
},
{ {
"dir": "packages/plugins/sandbox-providers/exe-dev", "dir": "packages/plugins/sandbox-providers/exe-dev",
"name": "@paperclipai/plugin-exe-dev", "name": "@paperclipai/plugin-exe-dev",

Some files were not shown because too many files have changed in this diff Show More