81 Commits

Author SHA1 Message Date
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
292 changed files with 10039 additions and 81984 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
+106
View File
@@ -0,0 +1,106 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
# Modify the existing node user/group to have the specified UID/GID to match host user
RUN usermod -u $USER_UID --non-unique node \
&& groupmod -g $USER_GID --non-unique node \
&& usermod -g $USER_GID -d /paperclip node
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY cli/package.json cli/
COPY server/package.json server/
COPY ui/package.json ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/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 }}
+1 -3
View File
@@ -29,11 +29,9 @@ jobs:
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm build - run: pnpm build
- run: google-chrome --version - run: npx playwright install --with-deps chromium
- name: Run e2e tests - name: Run e2e tests
env:
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e run: pnpm run test:e2e
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
+2 -12
View File
@@ -45,12 +45,6 @@ jobs:
- name: Validate Dockerfile deps stage - name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs run: node ./scripts/check-docker-deps-stage.mjs
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
- name: Validate release package manifest - name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check run: node ./scripts/release-package-map.mjs check
@@ -279,11 +273,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Verify runner Chrome - name: Install Playwright
# GitHub's Ubuntu runner image already ships Google Chrome, so use that run: npx playwright install --with-deps chromium
# directly for the headless e2e lane instead of downloading Playwright
# browser bundles inside the 30 minute job budget.
run: google-chrome --version
- name: Generate Paperclip config - name: Generate Paperclip config
run: | run: |
@@ -303,7 +294,6 @@ jobs:
- name: Run e2e tests - name: Run e2e tests
env: env:
PAPERCLIP_E2E_SKIP_LLM: "true" PAPERCLIP_E2E_SKIP_LLM: "true"
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:e2e run: pnpm run test:e2e
- name: Upload Playwright report - name: Upload Playwright report
+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 }}"
+2 -5
View File
@@ -58,10 +58,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --no-frozen-lockfile run: pnpm install --no-frozen-lockfile
- name: Verify runner Chrome - name: Install Playwright browser
# Release smoke also runs headless on GitHub's Ubuntu image, so use the run: npx playwright install --with-deps chromium
# runner's preinstalled Chrome instead of a Playwright browser download.
run: google-chrome --version
- name: Launch Docker smoke harness - name: Launch Docker smoke harness
run: | run: |
@@ -91,7 +89,6 @@ jobs:
PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }} PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }}
PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }} PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }}
PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }} PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }}
PAPERCLIP_PLAYWRIGHT_CHANNEL: "chrome"
run: pnpm run test:release-smoke run: pnpm run test:release-smoke
- name: Capture Docker logs - name: Capture Docker logs
+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,7 +22,6 @@ 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/
+20 -13
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="doc/assets/banner.jpg" alt="Paperclip is the app people use to manage AI agents for work." width="720" /> <img src="doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
</p> </p>
<p align="center"> <p align="center">
@@ -7,8 +7,7 @@
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot; <a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot; <a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> &middot; <a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> &middot;
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> &middot; <a href="https://x.com/papercliping"><strong>Twitter</strong></a>
<a href="https://paperclip.ing"><strong>Website</strong></a>
</p> </p>
<p align="center"> <p align="center">
@@ -25,15 +24,15 @@
<br/> <br/>
# Paperclip is the app people use to manage AI agents for work. ## What is Paperclip?
Open-source orchestration for teams of AI agents. # Open-source orchestration for zero-human companies
**If OpenClaw is an _employee_, Paperclip is the _company_.** **If OpenClaw is an _employee_, Paperclip is the _company_**
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track work and costs from one dashboard. Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard.
It looks like a task manager. Under the hood: org charts, budgets, governance, goal alignment, and agent coordination. It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination.
**Manage business goals, not pull requests.** **Manage business goals, not pull requests.**
@@ -45,6 +44,10 @@ It looks like a task manager. Under the hood: org charts, budgets, governance, g
<br/> <br/>
> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds.
<br/>
<div align="center"> <div align="center">
<table> <table>
<tr> <tr>
@@ -110,7 +113,7 @@ Every conversation traced. Every decision explained. Full tool-call tracing and
<tr> <tr>
<td align="center"> <td align="center">
<h3>🛡️ Governance</h3> <h3>🛡️ Governance</h3>
Approve hires, override strategy, pause or terminate any agent — at any time. You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
</td> </td>
<td align="center"> <td align="center">
<h3>📊 Org Chart</h3> <h3>📊 Org Chart</h3>
@@ -219,7 +222,7 @@ Paperclip is a full control plane, not a wrapper. Before you build any of this y
</td> </td>
<td> <td>
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. Nothing ships without your sign-off. **Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. You're the board — nothing ships without your sign-off.
</td> </td>
</tr> </tr>
@@ -314,7 +317,7 @@ This starts the API server at `http://localhost:3100`. An embedded PostgreSQL da
**What does a typical setup look like?** **What does a typical setup look like?**
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest. Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
If you're a solo entrepreneur you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it. If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
**Can I run multiple companies?** **Can I run multiple companies?**
Yes. A single deployment can run an unlimited number of companies with complete data isolation. Yes. A single deployment can run an unlimited number of companies with complete data isolation.
@@ -415,7 +418,7 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
## License ## License
MIT &copy; 2026 [Paperclip Labs, Inc](https://paperclip.ing) MIT &copy; 2026 Paperclip
## Star History ## Star History
@@ -426,5 +429,9 @@ MIT &copy; 2026 [Paperclip Labs, Inc](https://paperclip.ing)
--- ---
<p align="center"> <p align="center">
<sub>Open source under MIT. Built for people who want to get work done, not babysit agents.</sub> <img src="doc/assets/footer.jpg" alt="" width="720" />
</p>
<p align="center">
<sub>Open source under MIT. Built for people who want to run companies, not babysit agents.</sub>
</p> </p>
-506
View File
@@ -1,506 +0,0 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerSkillsCommands } from "../commands/client/skills.js";
import { resolveCompanySkillReference } from "../commands/client/skills.js";
const ORIGINAL_ENV = { ...process.env };
function makeProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => undefined,
writeErr: () => undefined,
});
registerSkillsCommands(program);
return program;
}
async function runCommand(args: string[]): Promise<void> {
await makeProgram().parseAsync(args, { from: "user" });
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function skill(overrides: Record<string, unknown> = {}) {
return {
id: "11111111-1111-1111-1111-111111111111",
companyId: "company-1",
key: "paperclip/review-prs",
slug: "review-prs",
name: "Review PRs",
description: "Review pull requests",
markdown: "# Review PRs",
sourceType: "local_path",
sourceLocator: null,
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: null,
createdAt: "2026-05-26T00:00:00.000Z",
updatedAt: "2026-05-26T00:00:00.000Z",
attachedAgentCount: 2,
editable: true,
editableReason: null,
sourceLabel: null,
sourceBadge: "local",
sourcePath: null,
...overrides,
};
}
function catalogSkill(overrides: Record<string, unknown> = {}) {
return {
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 pull requests, review responses, and verification notes.",
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"],
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 128, sha256: "sha256:abc" }],
contentHash: "sha256:catalog",
...overrides,
};
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-1",
companyId: "company-1",
name: "Coder",
role: "engineer",
status: "active",
reportsTo: null,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: "2026-05-26T00:00:00.000Z",
updatedAt: "2026-05-26T00:00:00.000Z",
...overrides,
};
}
describe("skills CLI helpers", () => {
it("resolves skill refs by id, key, or unique normalized slug", () => {
const rows = [
skill({ id: "skill-a", key: "paperclip/a", slug: "alpha", name: "Alpha" }),
skill({ id: "skill-b", key: "paperclip/b", slug: "beta-skill", name: "Beta" }),
];
expect(resolveCompanySkillReference(rows, "skill-a").key).toBe("paperclip/a");
expect(resolveCompanySkillReference(rows, "paperclip/b").id).toBe("skill-b");
expect(resolveCompanySkillReference(rows, "Beta Skill").id).toBe("skill-b");
});
it("rejects ambiguous slug refs", () => {
const rows = [
skill({ id: "skill-a", key: "paperclip/a", slug: "same", name: "A" }),
skill({ id: "skill-b", key: "paperclip/b", slug: "same", name: "B" }),
];
expect(() => resolveCompanySkillReference(rows, "same")).toThrow(/Ambiguous skill slug/);
});
});
describe("skills CLI commands", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let writeChunks: unknown[];
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
writeChunks = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
writeChunks.push(chunk);
return true;
});
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("lists company skills as JSON through the shared client context", async () => {
const rows = [skill()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"skills",
"list",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/skills",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ authorization: "Bearer token" }),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("resolves a skill slug before reading detail", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse({ ...skill(), usedByAgents: [] }));
await runCommand([
"skills",
"show",
"Review PRs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111",
expect.objectContaining({ method: "GET" }),
);
});
it("prints skill files as raw pipeable content in human mode", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse({
skillId: "11111111-1111-1111-1111-111111111111",
path: "SKILL.md",
kind: "skill",
content: "# Review PRs",
language: "markdown",
markdown: true,
editable: true,
}));
await runCommand([
"skills",
"file",
"review-prs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(logSpy).not.toHaveBeenCalled();
expect(writeChunks.join("")).toBe("# Review PRs\n");
});
it("browses catalog skills with filters in table output", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([catalogSkill()]));
await runCommand([
"skills",
"browse",
"--kind",
"bundled",
"--category",
"software-development",
"--query",
"github",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog?kind=bundled&category=software-development&q=github",
expect.objectContaining({ method: "GET" }),
);
const rendered = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(rendered).toContain("id");
expect(rendered).toContain("paperclipai:bundled:software-development:github-pr-workflow");
expect(rendered).toContain("roles");
});
it("searches catalog skills as JSON", async () => {
const rows = [catalogSkill()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"skills",
"search",
"pull requests",
"--kind",
"bundled",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog?kind=bundled&q=pull+requests",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("inspects catalog skill detail by query ref so keys with slashes work", async () => {
const detail = catalogSkill();
fetchMock.mockResolvedValueOnce(jsonResponse(detail));
await runCommand([
"skills",
"inspect",
"paperclipai/bundled/software-development/github-pr-workflow",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog/ref?ref=paperclipai%2Fbundled%2Fsoftware-development%2Fgithub-pr-workflow",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(detail);
});
it("installs catalog skills into the company library without agent sync", async () => {
const result = {
action: "created",
skill: skill({
key: "paperclipai/bundled/software-development/github-pr-workflow",
slug: "pr-flow",
sourceType: "catalog",
}),
catalogSkill: catalogSkill(),
warnings: [],
};
fetchMock.mockResolvedValueOnce(jsonResponse(result, 201));
await runCommand([
"skills",
"install",
"github-pr-workflow",
"--as",
"pr-flow",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/skills/install-catalog",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
catalogSkillId: "github-pr-workflow",
slug: "pr-flow",
force: true,
}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
});
it("passes force to skill updates", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse(skill({ sourceRef: "sha256:new" })));
await runCommand([
"skills",
"update",
"review-prs",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/install-update",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ force: true }),
}),
);
});
it("audits installed skill bytes through the server", async () => {
const audit = {
skillId: "11111111-1111-1111-1111-111111111111",
installedHash: "sha256:installed",
originHash: "sha256:origin",
verdict: "warning",
codes: ["network_reference"],
findings: [{
code: "network_reference",
severity: "warning",
message: "Skill content references network-capable commands or URLs.",
path: "SKILL.md",
}],
scannedAt: "2026-05-26T00:00:00.000Z",
scanVersion: "skills-audit-v1",
};
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse(audit));
await runCommand([
"skills",
"audit",
"review-prs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/audit",
expect.objectContaining({
method: "POST",
body: JSON.stringify({}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(audit);
});
it("requires confirmation for reset and sends force when confirmed", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill({ sourceType: "catalog" })]))
.mockResolvedValueOnce(jsonResponse(skill({ sourceType: "catalog" })));
await runCommand([
"skills",
"reset",
"review-prs",
"--yes",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/reset",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ force: true }),
}),
);
});
it("syncs desired company skill refs to an agent and returns the runtime snapshot", async () => {
const snapshot = {
adapterType: "codex_local",
supported: true,
mode: "persistent",
desiredSkills: ["paperclip/review-prs"],
entries: [
{
key: "paperclip/review-prs",
runtimeName: "review-prs",
desired: true,
managed: true,
required: false,
state: "installed",
origin: "company_managed",
detail: null,
},
],
warnings: [],
};
fetchMock
.mockResolvedValueOnce(jsonResponse(agent()))
.mockResolvedValueOnce(jsonResponse(snapshot));
await runCommand([
"skills",
"agent",
"sync",
"coder",
"--skill",
"review-prs",
"--skill",
"paperclip/qa",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/agents/coder?companyId=company-1",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/agents/agent-1/skills/sync",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ desiredSkills: ["review-prs", "paperclip/qa"] }),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(snapshot);
});
});
File diff suppressed because it is too large Load Diff
-2
View File
@@ -20,7 +20,6 @@ import { registerRoutineCommands } from "./commands/routines.js";
import { registerFeedbackCommands } from "./commands/client/feedback.js"; import { registerFeedbackCommands } from "./commands/client/feedback.js";
import { registerSecretCommands } from "./commands/client/secrets.js"; import { registerSecretCommands } from "./commands/client/secrets.js";
import { registerCloudCommands } from "./commands/client/cloud.js"; import { registerCloudCommands } from "./commands/client/cloud.js";
import { registerSkillsCommands } from "./commands/client/skills.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js"; import { loadPaperclipEnvFile } from "./config/env.js";
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
@@ -152,7 +151,6 @@ registerRoutineCommands(program);
registerFeedbackCommands(program); registerFeedbackCommands(program);
registerSecretCommands(program); registerSecretCommands(program);
registerCloudCommands(program); registerCloudCommands(program);
registerSkillsCommands(program);
registerWorktreeCommands(program); registerWorktreeCommands(program);
registerEnvLabCommands(program); registerEnvLabCommands(program);
registerPluginCommands(program); registerPluginCommands(program);
-118
View File
@@ -143,124 +143,6 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
pnpm paperclipai agent local-cli claudecoder --company-id <company-id> pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
``` ```
## Skills Commands
`paperclipai skills` covers three distinct operations:
1. **Company install** — adds or updates a row in `company_skills` for the
whole company. This is what `skills install`, `skills import`, `skills create`,
and `skills scan-projects` do.
2. **Agent attach** — replaces an agent's *desired* company skill set
(`skills agent sync`/`clear`). This is a desired-state operation on the
agent's adapter config; it does not change the company library.
3. **Adapter runtime sync** — the adapter reconciles the desired skill set
with files on disk and reports an `AgentSkillSnapshot` (`skills agent list`).
`skills agent sync` triggers this automatically after updating desired state.
Required Paperclip runtime skills (heartbeat, etc.) remain server-enforced and
are added on top of whatever the desired set names.
### Catalog (app-shipped skills)
The Paperclip app ships a curated catalog under `@paperclipai/skills-catalog`.
Browse and inspect commands never mutate company state; `install` adds a catalog
skill to the company library.
```sh
pnpm paperclipai skills browse [--kind bundled|optional] [--category <slug>] [--query <text>]
pnpm paperclipai skills search "<text>" [--kind bundled|optional] [--category <slug>]
pnpm paperclipai skills inspect <catalog-id-or-key-or-slug>
pnpm paperclipai skills install <catalog-id-or-key-or-slug> [--as <slug>] [--force] --company-id <company-id>
```
Catalog semantics:
- **Bundled** skills live in `packages/skills-catalog/catalog/bundled/<category>/<slug>`
and are recommended defaults for most companies. They use canonical key
`paperclipai/bundled/<category>/<slug>`.
- **Optional** skills live in `packages/skills-catalog/catalog/optional/<category>/<slug>`
and are role-specific or domain-specific (browser, AWS ops, etc.). Same key
shape with `optional` in place of `bundled`.
- `skills install` materializes the catalog files into a company-managed skill
directory and records provenance (`catalogId`, `catalogKey`, `packageVersion`,
`originHash`, …) so future updates and audit decisions stay consistent.
- `--as <slug>` overrides the company skill slug. `--force` may replace a
same-key catalog-managed skill but never bypasses hard validation or hard-stop
audit findings.
Examples:
```sh
pnpm paperclipai skills browse --kind bundled --company-id <company-id>
pnpm paperclipai skills search "pull request" --kind bundled
pnpm paperclipai skills inspect github-pr-workflow
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
pnpm paperclipai skills install paperclipai:optional:browser:agent-browser --company-id <company-id>
```
External GitHub, skills.sh, local-path, and URL sources still go through
`skills import`; catalog commands are for the app-shipped catalog only.
### Company library
```sh
pnpm paperclipai skills list --company-id <company-id>
pnpm paperclipai skills show <skill-id-or-key-or-slug> --company-id <company-id>
pnpm paperclipai skills file <skill-id-or-key-or-slug> [--path SKILL.md] --company-id <company-id>
pnpm paperclipai skills import <source> --company-id <company-id>
pnpm paperclipai skills create --name "Review PRs" [--slug review-prs] [--description "..."] [--body-file SKILL.md] --company-id <company-id>
pnpm paperclipai skills scan-projects [--project-id <id>...] [--workspace-id <id>...] --company-id <company-id>
pnpm paperclipai skills check [skill-id-or-key-or-slug] --company-id <company-id>
pnpm paperclipai skills update <skill-id-or-key-or-slug> [--force] --company-id <company-id>
pnpm paperclipai skills update --all [--force] --company-id <company-id>
pnpm paperclipai skills audit [skill-id-or-key-or-slug] --company-id <company-id>
pnpm paperclipai skills reset <skill-id-or-key-or-slug> [--yes] [--force] --company-id <company-id>
pnpm paperclipai skills remove <skill-id-or-key-or-slug> --yes --company-id <company-id>
```
`skills import <source>` accepts a skills.sh URL, the equivalent
`<owner>/<repo>/<skill>` shorthand, a GitHub URL, a local path, or an
`npx skills add …` command. See `references/company-skills.md` in the agent
skill bundle for the source-type table.
`skills check`, `skills update`, `skills audit`, and `skills reset` are the
maintenance loop for catalog-installed skills:
- `check` reports whether each skill's installed bytes match its pinned origin
(`hasUpdate`, `installedHash`, `originHash`, `updateHoldReason`,
`auditVerdict`).
- `update` installs the pinned update through the existing install-update API.
`--all` checks every company skill and updates only those with
`hasUpdate=true`. `--force` discards local-modification or soft-audit holds;
hard-stop audit findings still block the update.
- `audit` re-scans installed bytes and reports findings without executing
anything.
- `reset` reinstalls a catalog-managed skill from its pinned origin, discarding
local edits. Prompts in a TTY; requires `--yes` for non-interactive use.
### Agent attach
```sh
pnpm paperclipai skills agent list <agent-id-or-shortname> --company-id <company-id>
pnpm paperclipai skills agent sync <agent-id-or-shortname> --skill <skill-id-or-key-or-slug> [--skill <skill-id-or-key-or-slug>...] --company-id <company-id>
pnpm paperclipai skills agent clear <agent-id-or-shortname> --yes --company-id <company-id>
```
`skills agent sync` replaces the agent's non-required desired skill set (it is
not additive) and returns the resulting adapter `AgentSkillSnapshot`.
`skills agent clear` sends an empty desired list. Required Paperclip skills are
still enforced by the server in both cases.
### Notes
- Skill references accept company skill `id`, canonical `key`, or unique
`slug`; catalog references accept catalog `id`, `key`, or unique `slug`.
- `skills file` prints raw file content in human mode so it can be piped.
- `skills create --body-file -` reads the skill markdown body from stdin.
- `skills remove`, `skills reset`, and `skills agent clear` prompt in a TTY and
require `--yes` in non-interactive use.
- `--json` prints the raw API result for each command.
## Secrets Commands ## Secrets Commands
```sh ```sh
+3 -34
View File
@@ -125,50 +125,19 @@ When running `authenticated` mode, if the only instance admin is `local-board`,
This prevents lockout when a user migrates from long-running local trusted usage to authenticated mode. This prevents lockout when a user migrates from long-running local trusted usage to authenticated mode.
## 8. First Admin Setup For Fresh Authenticated Installs ## 8. Current Code Reality (As Of 2026-02-23)
Fresh authenticated installs start in `bootstrap_pending` until the first
`instance_admin` exists.
For `authenticated/private`, Paperclip supports a browser-first setup path:
1. open the Paperclip URL from the private network or appliance UI
2. sign in or create a Paperclip account
3. choose `Claim this instance` on the setup screen
That browser claim promotes the signed-in session user to the first instance
admin and then falls through to normal onboarding. The endpoint is available
only to real browser session actors in `authenticated/private`; unauthenticated
requests, agent keys, board API keys, and local implicit board actors are
rejected.
The CLI fallback remains supported in all authenticated setup states:
```sh
pnpm paperclipai auth bootstrap-ceo
```
That command prints a one-time first-admin invite URL. Browser claim and
bootstrap invite acceptance share the same first-admin transaction, so whichever
path wins first makes later attempts return a conflict.
For `authenticated/public`, browser first-admin claim is intentionally disabled.
Public deployments must use the high-entropy bootstrap invite path unless a
future public-hosted setup design explicitly changes this policy.
## 9. Current Code Reality (As Of 2026-02-23)
- runtime values are `local_trusted | authenticated` - runtime values are `local_trusted | authenticated`
- `authenticated` uses Better Auth sessions and bootstrap invite flow - `authenticated` uses Better Auth sessions and bootstrap invite flow
- `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access - `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access
- company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent - company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent
## 10. Naming and Compatibility Policy ## 9. Naming and Compatibility Policy
- canonical naming is `local_trusted` and `authenticated` with `private/public` exposure - canonical naming is `local_trusted` and `authenticated` with `private/public` exposure
- no long-term compatibility alias layer for discarded naming variants - no long-term compatibility alias layer for discarded naming variants
## 11. Relationship to Other Docs ## 10. Relationship to Other Docs
- implementation plan: `doc/plans/deployment-auth-mode-consolidation.md` - implementation plan: `doc/plans/deployment-auth-mode-consolidation.md`
- V1 contract: `doc/SPEC-implementation.md` - V1 contract: `doc/SPEC-implementation.md`
-63
View File
@@ -72,13 +72,6 @@ pnpm dev --bind lan
``` ```
This runs dev as `authenticated/private` with a private-network bind preset. This runs dev as `authenticated/private` with a private-network bind preset.
On a fresh authenticated/private instance, open the app, sign in or create an
account, and use the setup screen to claim the first instance admin from the
browser. The CLI fallback remains:
```sh
pnpm paperclipai auth bootstrap-ceo
```
For Tailscale-only reachability on a detected tailnet address: For Tailscale-only reachability on a detected tailnet address:
@@ -420,62 +413,6 @@ eval "$(pnpm paperclipai worktree env)"
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants. For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
## App-Shipped Skills Catalog
The Paperclip app ships a curated catalog of company skills out of the box. The
catalog is a workspace package at `packages/skills-catalog`:
```text
packages/skills-catalog/
catalog/
bundled/<category>/<slug>/SKILL.md # recommended defaults
optional/<category>/<slug>/SKILL.md # role/domain-specific
generated/catalog.json # checked-in manifest
scripts/
build-catalog-manifest.ts # regenerate generated/catalog.json
validate-catalog.ts # validation only
src/ # builder + types consumed by server/CLI
```
Server and CLI import the generated manifest; they do not crawl repository
paths at request time. Root `skills/` remains reserved for Paperclip runtime
skills and is not part of the catalog.
Validate the catalog without writing the manifest:
```sh
pnpm --filter @paperclipai/skills-catalog validate
```
Regenerate `generated/catalog.json` after editing any catalog `SKILL.md`,
frontmatter, file inventory, category, or slug:
```sh
pnpm --filter @paperclipai/skills-catalog build:manifest
```
The package's `build` script runs `build:manifest` and then `tsc`; tests live
under `pnpm --filter @paperclipai/skills-catalog test`. Validation fails when:
- a catalog entry is not under `catalog/bundled/<category>/<slug>` or
`catalog/optional/<category>/<slug>`
- `SKILL.md` is missing or the frontmatter `name`/`description` is empty
- the frontmatter `key` disagrees with the generated canonical key
- two catalog entries share an `id`, `key`, or `slug`
- file inventory contains absolute paths, `..`, broken symlinks, or files
outside the skill directory
- the regenerated manifest differs from the checked-in
`generated/catalog.json`
Trust level is derived from inventory: `markdown_only` (markdown + references
only), `assets` (other non-script files), or `scripts_executables` (any
executable script). The build contract is documented in
`doc/plans/2026-05-26-skills-cli-catalog-contract.md`.
CI runs `pnpm --filter @paperclipai/skills-catalog validate` and the package's
vitest suite, so always regenerate the manifest in the same commit as the
catalog change.
## Quick Health Checks ## Quick Health Checks
In another terminal: In another terminal:
-10
View File
@@ -117,16 +117,6 @@ services:
- bootstrap invite URL defaults - bootstrap invite URL defaults
- hostname allowlist defaults (hostname extracted from URL) - hostname allowlist defaults (hostname extracted from URL)
For fresh `authenticated/private` Docker or appliance-style installs, the first
admin can now be claimed entirely from the browser after sign-in. Open the
Paperclip URL, sign in or create an account, then choose `Claim this instance`
on the setup screen. This browser claim is disabled for `authenticated/public`;
public deployments should run the high-entropy CLI invite fallback instead:
```sh
pnpm paperclipai auth bootstrap-ceo
```
Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`). Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`).
Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames). Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

+11 -77
View File
@@ -1,7 +1,7 @@
# Execution Semantics # Execution Semantics
Status: Current implementation guide Status: Current implementation guide
Date: 2026-05-23 Date: 2026-04-26
Audience: Product and engineering Audience: Product and engineering
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships. This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
@@ -152,73 +152,7 @@ Blocked issues should stay idle while blockers remain unresolved. Paperclip shou
If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone. If a parent is truly waiting on a child, model that with blockers. Do not rely on the parent/child relationship alone.
## 7. Accepted-Plan Decomposition ## 7. Non-Terminal Issue Liveness Contract
An accepted plan confirmation is permission to decompose one specific accepted plan revision into child issues.
This complements the existing accepted-plan continuation rule: once a plan is accepted, the source issue may create child implementation issues, but it must not start implementation work on the source issue itself during that continuation.
Paperclip must treat accepted-plan decomposition as an exact-once control-plane primitive, not as a free-floating wake that any later run may interpret again.
### Exact-once fingerprint
The canonical decomposition fingerprint is:
- `(sourceIssueId, acceptedPlanRevisionId)`
Where:
- `sourceIssueId` is the issue whose `plan` document revision was accepted
- `acceptedPlanRevisionId` is the accepted `plan` document revision
This is the product contract because the accepted revision is the thing being authorized for decomposition. Re-accepting, re-waking, or re-reading the same accepted revision must not authorize a second child tree. A later accepted revision on the same source issue is a new fingerprint and may produce a different decomposition result.
An implementation may also store the accepted interaction id, acceptance run id, or other evidence, but those values must collapse onto the same uniqueness guarantee. They must not allow a second decomposition claim for the same `(sourceIssueId, acceptedPlanRevisionId)` pair.
### Durable claim and durable result
Before creating child issues, the first decomposition attempt must create or reuse a durable record for the fingerprint.
That durable record must be able to answer, without reconstructing the thread from comments or transcripts:
- whether decomposition for the fingerprint is `in_flight` or `completed`
- which run or owner currently holds the in-flight claim
- which child issues, if any, have already been created under that fingerprint
- which final child issue ids belong to the completed result
Paperclip does not need to mandate a specific storage shape in this document. The record may live in a dedicated table, source-issue execution state, interaction metadata, or another durable product surface. What matters is the contract:
- the claim is durable before fan-out starts
- partial progress is durable while fan-out is underway
- the completed child result set is durable after fan-out finishes
If a run creates some children and then dies, retries must continue from the same fingerprint and reuse the already-recorded partial result. They must not restart decomposition as if nothing happened.
### Parent live path while decomposition is in flight
While decomposition for an accepted fingerprint is incomplete, the source issue must expose an explicit live path for that same fingerprint.
The accepted interaction by itself is only evidence that the plan was approved. It is not a sufficient live path once decomposition begins. The source issue must make it clear what moves the fingerprint forward next, such as:
- the active decomposition run
- a queued continuation wake for the same assignee
- a monitor or explicit recovery action tied to the same decomposition claim
- a blocked state that names the real blocker for finishing that claimed decomposition
If the live run disappears, Paperclip must repair, resume, or visibly block the existing claim. It must not leave the source issue in a state where a second run can interpret the same acceptance as fresh permission to create sibling issues again.
### Concurrent and repeat attempts
Every later run that encounters the same accepted-plan fingerprint must consult the durable claim/result before creating children.
- If no claim exists, the run may atomically create the claim and become the decomposition owner.
- If a claim exists and is `in_flight`, the later run must reuse that claim. It may resume the same decomposition if it is the valid continuation owner, or it may exit after observing that another run already owns the work.
- If a claim exists and is `completed`, the later run must reuse the recorded child result and must not create new sibling issues.
- If the prior attempt ended after partial child creation, the retry must continue under the same fingerprint and preserve the already-created child ids.
Concurrent accepted-plan runs are therefore idempotent relative to the fingerprint. Creating multiple child trees for the same `(sourceIssueId, acceptedPlanRevisionId)` pair is a product bug.
## 8. Non-Terminal Issue Liveness Contract
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it. For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
@@ -358,13 +292,13 @@ A blocker chain is covered only when its unresolved leaf is live or explicitly w
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered. A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
## 9. Crash and Restart Recovery ## 8. Crash and Restart Recovery
Paperclip now treats crash/restart recovery as a stranded-assigned-work problem, not just a stranded-run problem. Paperclip now treats crash/restart recovery as a stranded-assigned-work problem, not just a stranded-run problem.
There are two distinct failure modes. There are two distinct failure modes.
### 9.1 Stranded assigned `todo` ### 8.1 Stranded assigned `todo`
Example: Example:
@@ -380,7 +314,7 @@ Recovery rule:
This is a dispatch recovery, not a continuation recovery. This is a dispatch recovery, not a continuation recovery.
### 9.2 Stranded assigned `in_progress` ### 8.2 Stranded assigned `in_progress`
Example: Example:
@@ -396,13 +330,13 @@ Recovery rule:
This is an active-work continuity recovery. This is an active-work continuity recovery.
### 9.3 Recovery model-profile lane ### 8.3 Recovery model-profile lane
Cheap model profiles are only for status-only operational recovery overhead. Paperclip may request `modelProfile: "cheap"` for bounded recovery-owner work that updates task liveness, clears bad status, records a disposition, or asks for human/manager intervention. Those wakes must carry guard context such as `allowDeliverableWork: false`, `allowDocumentUpdates: false`, and `resumeRequiresNormalModel: true`. Cheap model profiles are only for status-only operational recovery overhead. Paperclip may request `modelProfile: "cheap"` for bounded recovery-owner work that updates task liveness, clears bad status, records a disposition, or asks for human/manager intervention. Those wakes must carry guard context such as `allowDeliverableWork: false`, `allowDocumentUpdates: false`, and `resumeRequiresNormalModel: true`.
Automatic retries that can continue source work must use the original/normal model lane. This includes failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, assigned-todo dispatch recovery, and any run that can update repo files, issue documents, plans, work products, or attachments. When a cheap status-only recovery determines that actual work remains, it must hand back to a normal-model worker run before source work or persistent deliverable updates resume. Cheap recovery hints must be scrubbed from copied retry, resume, child, and downstream source-work contexts. Automatic retries that can continue source work must use the original/normal model lane. This includes failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, assigned-todo dispatch recovery, and any run that can update repo files, issue documents, plans, work products, or attachments. When a cheap status-only recovery determines that actual work remains, it must hand back to a normal-model worker run before source work or persistent deliverable updates resume. Cheap recovery hints must be scrubbed from copied retry, resume, child, and downstream source-work contexts.
## 10. Startup and Periodic Reconciliation ## 9. Startup and Periodic Reconciliation
Startup recovery and periodic recovery are different from normal wakeup delivery. Startup recovery and periodic recovery are different from normal wakeup delivery.
@@ -416,7 +350,7 @@ On startup and on the periodic recovery loop, Paperclip now does five things in
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition. The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
## 11. Silent Active-Run Watchdog ## 10. Silent Active-Run Watchdog
An active run can still be unhealthy even when its process is `running`. Paperclip treats prolonged output silence as a watchdog signal, not as proof that the run is failed. An active run can still be unhealthy even when its process is `running`. Paperclip treats prolonged output silence as a watchdog signal, not as proof that the run is failed.
@@ -468,7 +402,7 @@ This is distinct from productivity review. Productivity review asks whether an a
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains. Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
## 12. Auto-Recover vs Explicit Recovery vs Human Escalation ## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
Paperclip uses three different recovery outcomes, depending on how much it can safely infer. Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
@@ -512,7 +446,7 @@ Examples:
In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying. In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying.
## 13. What This Does Not Mean ## 12. What This Does Not Mean
These semantics do not change V1 into an auto-reassignment system. These semantics do not change V1 into an auto-reassignment system.
@@ -529,7 +463,7 @@ The recovery model is intentionally conservative:
- open an explicit recovery action when the system can identify a bounded recovery owner/action - open an explicit recovery action when the system can identify a bounded recovery owner/action
- escalate visibly when the system cannot safely keep going - escalate visibly when the system cannot safely keep going
## 14. Practical Interpretation ## 13. Practical Interpretation
For a board operator, the intended meaning is: For a board operator, the intended meaning is:
@@ -1,6 +1,6 @@
# 2026-03-14 Adapter Skill Sync Rollout # 2026-03-14 Adapter Skill Sync Rollout
Status: Implemented for local adapters; gateway remains unsupported Status: Proposed
Date: 2026-03-14 Date: 2026-03-14
Audience: Product and engineering Audience: Product and engineering
Related: Related:
@@ -25,10 +25,8 @@ Paperclip currently has these adapters:
- `claude_local` - `claude_local`
- `codex_local` - `codex_local`
- `cursor` - `cursor_local`
- `gemini_local` - `gemini_local`
- `grok_local`
- `acpx_local`
- `opencode_local` - `opencode_local`
- `pi_local` - `pi_local`
- `openclaw_gateway` - `openclaw_gateway`
@@ -41,14 +39,12 @@ The current skill API supports:
Current implementation state: Current implementation state:
- `codex_local`: implemented, `ephemeral` - `codex_local`: implemented, `persistent`
- `claude_local`: implemented, `ephemeral` - `claude_local`: implemented, `ephemeral`
- `cursor`: implemented, `persistent` - `cursor_local`: not yet implemented, but technically suited to `persistent`
- `gemini_local`: implemented, `persistent` - `gemini_local`: not yet implemented, but technically suited to `persistent`
- `pi_local`: implemented, `persistent` - `pi_local`: not yet implemented, but technically suited to `persistent`
- `opencode_local`: implemented, `persistent`, with shared Claude skills home caveats - `opencode_local`: not yet implemented; likely `persistent`, but with special handling because it currently injects into Claudes shared skills home
- `acpx_local`: implemented, `ephemeral` for Claude/Codex sub-agents and `unsupported` for custom commands
- `grok_local`: implemented, `ephemeral`
- `openclaw_gateway`: not yet implemented; blocked on gateway protocol support, so `unsupported` for now - `openclaw_gateway`: not yet implemented; blocked on gateway protocol support, so `unsupported` for now
## 3. Product Principles ## 3. Product Principles
@@ -68,7 +64,8 @@ These adapters have a stable local skills directory that Paperclip can read and
Candidates: Candidates:
- `cursor` - `codex_local`
- `cursor_local`
- `gemini_local` - `gemini_local`
- `pi_local` - `pi_local`
- `opencode_local` with caveats - `opencode_local` with caveats
@@ -87,10 +84,7 @@ These adapters do not have a meaningful Paperclip-owned persistent install state
Current adapter: Current adapter:
- `codex_local`
- `claude_local` - `claude_local`
- `acpx_local` when configured for Claude or Codex
- `grok_local`
Expected UX: Expected UX:
@@ -105,7 +99,6 @@ These adapters cannot support skill sync without new external capabilities.
Current adapter: Current adapter:
- `acpx_local` when configured for custom commands
- `openclaw_gateway` - `openclaw_gateway`
Expected UX: Expected UX:
@@ -121,7 +114,7 @@ Expected UX:
Target mode: Target mode:
- `ephemeral` - `persistent`
Current state: Current state:
@@ -129,15 +122,15 @@ Current state:
Requirements to finish: Requirements to finish:
- keep runtime-mounted snapshots separate from persistent install snapshots - keep as reference implementation
- ensure imported company skills can be attached and mounted without manual path work - tighten tests around external custom skills and stale removal
- keep `CODEX_HOME/skills` mutation scoped to heartbeat execution, not `skills/sync` - ensure imported company skills can be attached and synced without manual path work
Success criteria: Success criteria:
- desired skills are stored in Paperclip - list installed managed and external skills
- selected skills are linked into the effective `CODEX_HOME/skills` during runs - sync desired skills into `CODEX_HOME/skills`
- no persistent installed/stale state is reported from `skills/sync` - preserve external user-managed skills
### 5.2 Claude Local ### 5.2 Claude Local
@@ -169,11 +162,18 @@ Target mode:
Technical basis: Technical basis:
- Paperclip reconciles desired skills into `~/.cursor/skills` - runtime already injects Paperclip skills into `~/.cursor/skills`
Current state: Implementation work:
- implemented 1. Add `listSkills` for Cursor.
2. Add `syncSkills` for Cursor.
3. Reuse the same managed-symlink pattern as Codex.
4. Distinguish:
- managed Paperclip skills
- external skills already present
- missing desired skills
- stale managed skills
Testing: Testing:
@@ -194,11 +194,14 @@ Target mode:
Technical basis: Technical basis:
- Paperclip reconciles desired skills into `~/.gemini/skills` - runtime already injects Paperclip skills into `~/.gemini/skills`
Current state: Implementation work:
- implemented 1. Add `listSkills` for Gemini.
2. Add `syncSkills` for Gemini.
3. Reuse managed-symlink conventions from Codex/Cursor.
4. Verify auth remains untouched while skills are reconciled.
Potential caveat: Potential caveat:
@@ -216,11 +219,14 @@ Target mode:
Technical basis: Technical basis:
- Paperclip reconciles desired skills into `~/.pi/agent/skills` - runtime already injects Paperclip skills into `~/.pi/agent/skills`
Current state: Implementation work:
- implemented 1. Add `listSkills` for Pi.
2. Add `syncSkills` for Pi.
3. Reuse managed-symlink helpers.
4. Verify session-file behavior remains independent from skill sync.
Success criteria: Success criteria:
@@ -244,7 +250,9 @@ This is product-risky because:
Plan: Plan:
- implemented `listSkills` and `syncSkills` Phase 1:
- implement `listSkills` and `syncSkills`
- treat it as `persistent` - treat it as `persistent`
- explicitly label the home as shared in UI copy - explicitly label the home as shared in UI copy
- only remove stale managed Paperclip skills that are clearly marked as Paperclip-managed - only remove stale managed Paperclip skills that are clearly marked as Paperclip-managed
@@ -282,30 +290,6 @@ Future target:
- likely a fourth truth model eventually, such as remote-managed persistent state - likely a fourth truth model eventually, such as remote-managed persistent state
- for now, keep the current API and treat gateway as unsupported - for now, keep the current API and treat gateway as unsupported
### 5.8 ACPX Local
Target mode:
- `ephemeral` for built-in Claude/Codex ACPX sub-agents
- `unsupported` for custom ACP commands
Success criteria:
- Claude/Codex ACPX snapshots show skills as configured for the next session
- custom command snapshots keep desired skills tracked only and do not imply runtime sync
### 5.9 Grok Local
Target mode:
- `ephemeral`
Success criteria:
- desired skills are stored in Paperclip
- selected skills are copied into the execution workspace for the next run
- no persistent installed/stale state is reported from `skills/sync`
## 6. API Plan ## 6. API Plan
## 6.1 Keep the current minimal adapter API ## 6.1 Keep the current minimal adapter API
@@ -349,13 +333,14 @@ Additional UI requirement for shared-home adapters:
Ship: Ship:
- `cursor` - `cursor_local`
- `gemini_local` - `gemini_local`
- `pi_local` - `pi_local`
Status: Rationale:
- implemented - these are the closest to Codex in architecture
- they already inject into stable local skill homes
### Phase 2: OpenCode shared-home support ### Phase 2: OpenCode shared-home support
@@ -363,9 +348,10 @@ Ship:
- `opencode_local` - `opencode_local`
Status: Rationale:
- implemented with shared Claude skills-home warning - technically feasible now
- needs slightly more careful product language because of the shared Claude skills home
### Phase 3: Gateway support decision ### Phase 3: Gateway support decision
@@ -404,10 +390,10 @@ Adapter-wide skill support is ready when all are true:
The recommended immediate order is: The recommended immediate order is:
1. `cursor` 1. `cursor_local`
2. `gemini_local` 2. `gemini_local`
3. `pi_local` 3. `pi_local`
4. `opencode_local` 4. `opencode_local`
5. defer `openclaw_gateway` 5. defer `openclaw_gateway`
The local-adapter family now has explicit truth models. The remaining V1 boundary is `openclaw_gateway`, which should stay unsupported until the gateway protocol can report real remote skill state. That gets Paperclip from “skills work for Codex and Claude” to “skills work for the whole local-adapter family,” which is the meaningful V1 milestone.
@@ -1,486 +0,0 @@
# Skills CLI And Catalog Contract
Status: Phase A engineering contract
Date: 2026-05-26
Source plan: approved Paperclip skills CLI and catalog plan
This document freezes the first implementation contract for the `paperclipai skills`
command group and the app-shipped skills catalog. It is intentionally a build
contract, not a full product spec.
## Decisions
- `paperclipai skills` manages Paperclip company skills. It does not manage
local adapter homes directly.
- Installing a skill means adding or updating a company-scoped
`company_skills` record.
- Attaching a skill to an agent is a separate agent desired-state operation.
- Adapter runtime sync is a third step handled through adapter skill APIs.
- Root `skills/` remains reserved for Paperclip runtime and operational skills.
- App-shipped catalog skills live in `packages/skills-catalog`, not root
`skills/`.
- Catalog skills are inspectable before install. Inspection never mutates company
state.
- External sources continue to use the existing company skill import API in the
first release. No separate marketplace, tap, or source registry is part of this
phase.
- Agent desired skills continue to live in
`adapterConfig.paperclipSkillSync.desiredSkills` for the first release. Do not
add a normalized `agent_skills` table unless later implementation evidence
requires it.
## Terms
- Company skill: a row in `company_skills`, owned by one company.
- Catalog skill: an app-shipped skill entry in `@paperclipai/skills-catalog`.
- Skill ref: a user-supplied company skill reference. The CLI accepts company
skill `id`, canonical `key`, or unique `slug`.
- Catalog ref: a user-supplied catalog reference. The CLI accepts catalog `id`,
canonical `key`, or unique `slug`.
- Desired skills: the skill key set stored on the agent adapter config.
- Runtime snapshot: the adapter-reported `AgentSkillSnapshot` for desired,
installed, missing, stale, external, required, or unsupported skills.
## CLI Contract
All skills commands use the existing client command stack:
- Global client options: `--data-dir`, `--config`, `--context`, `--profile`,
`--api-base`, `--api-key`, and `--json`.
- Company-scoped commands also accept `-C, --company-id <id>` and otherwise use
`PAPERCLIP_COMPANY_ID` or the active context profile.
- Human output goes to stdout. Errors go to stderr.
- `--json` prints pretty JSON and no decorative labels.
- Successful commands exit `0`. Validation, API, or conflict errors exit `1`.
- API errors use the existing `API error <status>: <message>` formatting.
- Mutating commands print a short summary in human mode and the raw result in
JSON mode.
- Commands that can delete or clear state must prompt in a TTY. In non-TTY mode
they must require `--yes`.
### Company Skill Commands
These commands are Phase B and must work over existing APIs.
| Command | Behavior | JSON output |
|---|---|---|
| `skills list` | Lists company skills from `GET /api/companies/:companyId/skills`. Human rows include `id`, `key`, `slug`, `name`, `source`, `trust`, `compatibility`, and `attachedAgents`. | `CompanySkillListItem[]` |
| `skills show <skill-ref>` | Resolves `id`, `key`, or unique `slug`, then reads detail. Ambiguous slugs are conflicts. | `CompanySkillDetail` |
| `skills file <skill-ref> [--path <path>]` | Resolves the skill, reads a file with default `SKILL.md`, and prints raw file content in human mode. This command must remain pipeable. | `CompanySkillFileDetail` |
| `skills import <source>` | Calls existing import API. Source may be a local path, GitHub URL, skills.sh URL or command, `owner/repo`, `owner/repo/skill`, or URL-like source already accepted by the server. | `CompanySkillImportResult` |
| `skills create --name <name> [--slug <slug>] [--description <text>] [--body-file <path|->]` | Creates a managed local company skill. If `--body-file` is omitted, the server default body is used. `-` reads markdown from stdin. | `CompanySkill` |
| `skills scan-projects [--project-id <id>...] [--workspace-id <id>...]` | Calls project scan. Repeated flags become arrays. With neither flag, scan all accessible project workspaces. | `CompanySkillProjectScanResult` |
| `skills check [skill-ref]` | Reads update status for one skill, or for every listed company skill when no ref is provided. Unsupported statuses are shown, not hidden. | `CompanySkillCheckRow[]` |
| `skills update <skill-ref>` | Installs the update for one skill through the existing install-update API. | `CompanySkillUpdateRow` |
| `skills update --all` | Checks all skills, installs only those with `hasUpdate=true`, and reports skipped unsupported or current skills. | `CompanySkillUpdateRow[]` |
| `skills remove <skill-ref> [--yes]` | Deletes one company skill after confirmation. | `CompanySkill` |
`CompanySkillCheckRow` is a CLI-side shape:
```ts
interface CompanySkillCheckRow {
skill: Pick<CompanySkillListItem, "id" | "key" | "slug" | "name">;
status: CompanySkillUpdateStatus;
}
```
`CompanySkillUpdateRow` is a CLI-side shape:
```ts
interface CompanySkillUpdateRow {
skillRef: string;
action: "updated" | "skipped" | "failed";
skill?: CompanySkill;
status?: CompanySkillUpdateStatus;
reason?: string;
}
```
### Agent Skill Commands
These commands are Phase B and use existing agent skill APIs.
| Command | Behavior | JSON output |
|---|---|---|
| `skills agent list <agent-ref>` | Resolves the agent using existing agent reference behavior, then prints the adapter `AgentSkillSnapshot`. Human rows include `key`, `runtimeName`, `desired`, `managed`, `required`, `state`, `origin`, and `detail`. | `AgentSkillSnapshot` |
| `skills agent sync <agent-ref> --skill <skill-ref>...` | Replaces the agent's non-required desired skill set with the supplied refs and triggers adapter sync. Required Paperclip skills remain enforced by the server. | `AgentSkillSnapshot` |
| `skills agent clear <agent-ref> [--yes]` | Clears non-required desired skills by sending an empty desired list, then returns the adapter snapshot. | `AgentSkillSnapshot` |
The word `sync` is deliberate: it is a desired-state replacement, not an append.
An additive command can be added later if operators need it.
### Catalog CLI Commands
These commands are Phase E and depend on the catalog APIs from Phase D.
| Command | Behavior | JSON output |
|---|---|---|
| `skills browse [--kind bundled|optional] [--category <slug>] [--query <text>]` | Lists app-shipped catalog skills. Human rows include `id`, `key`, `kind`, `category`, `slug`, `name`, `trust`, and `recommendedForRoles`. | `CatalogSkillListItem[]` |
| `skills search <query> [--kind bundled|optional] [--category <slug>]` | Alias for catalog browse with `query`. | `CatalogSkillListItem[]` |
| `skills inspect <catalog-ref>` | Shows app-shipped catalog detail and file inventory. Does not mutate company state. | `CatalogSkillDetail` |
| `skills install <catalog-ref> [--as <slug>] [--force]` | Installs a catalog skill into a company library. `--as` overrides the company skill slug. `--force` may replace a same-key catalog skill but must not bypass hard validation or dangerous security findings. | `CompanySkillInstallCatalogResult` |
Catalog commands are for the app-shipped Paperclip catalog only. External GitHub,
skills.sh, local path, and URL installs remain under `skills import <source>` in
the first release.
## Catalog Package Contract
Add a workspace package:
```text
packages/skills-catalog/
package.json
tsconfig.json
src/
index.ts
types.ts
catalog/
bundled/
<category>/
<slug>/
SKILL.md
references/
scripts/
assets/
optional/
<category>/
<slug>/
SKILL.md
references/
scripts/
assets/
generated/
catalog.json
scripts/
build-catalog-manifest.ts
validate-catalog.ts
```
Package name: `@paperclipai/skills-catalog`.
The package exports:
- `catalogManifest`
- `catalogSkills`
- `resolveCatalogSkillRef(ref)`
- `getCatalogSkill(id)`
- TypeScript types for every manifest shape
Server and CLI code must import the generated manifest. They must not crawl
arbitrary repository paths at request time.
## Catalog Manifest
The generated artifact is `packages/skills-catalog/generated/catalog.json`.
It is checked in and regenerated by the package build or validation script.
```ts
interface CatalogManifest {
schemaVersion: 1;
packageName: "@paperclipai/skills-catalog";
packageVersion: string;
generatedAt: string;
skills: CatalogSkill[];
}
interface CatalogSkill {
id: string;
key: string;
kind: "bundled" | "optional";
category: string;
slug: string;
name: string;
description: string;
path: string;
entrypoint: "SKILL.md";
trustLevel: "markdown_only" | "assets" | "scripts_executables";
compatibility: "compatible" | "unknown" | "invalid";
defaultInstall: boolean;
recommendedForRoles: string[];
requires: string[];
tags: string[];
files: CatalogSkillFile[];
contentHash: string;
}
interface CatalogSkillFile {
path: string;
kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other";
sizeBytes: number;
sha256: string;
}
```
`id` is path-safe:
```text
paperclipai:<kind>:<category>:<slug>
```
`key` is the canonical company skill key installed into `company_skills`:
```text
paperclipai/<kind>/<category>/<slug>
```
Example:
```json
{
"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 pull requests, review responses, and verification notes.",
"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"],
"files": [
{
"path": "SKILL.md",
"kind": "skill",
"sizeBytes": 1200,
"sha256": "..."
}
],
"contentHash": "sha256:..."
}
```
## Catalog Skill Frontmatter
Each catalog `SKILL.md` must include:
```yaml
---
name: github-pr-workflow
description: Prepare pull requests, review responses, and verification notes.
key: paperclipai/bundled/software-development/github-pr-workflow
recommendedForRoles:
- engineer
tags:
- github
- pull-requests
---
```
Optional frontmatter:
- `slug`
- `defaultInstall`
- `requires`
- `metadata`
The manifest generator owns `kind`, `category`, `path`, `files`,
`trustLevel`, `compatibility`, and `contentHash`.
## Catalog Validation Rules
Validation must fail when:
- A catalog entry is not under `catalog/bundled/<category>/<slug>` or
`catalog/optional/<category>/<slug>`.
- `SKILL.md` is missing.
- `category` or `slug` is not a lowercase URL slug.
- `name` or `description` frontmatter is missing or empty.
- The frontmatter `key`, when present, does not equal the generated key.
- Two catalog entries have the same `id`, `key`, or `slug`.
- File inventory includes absolute paths, `..` segments, broken symlinks, or
files outside the skill directory.
- A file exceeds the package-level size limit chosen by implementation.
- A skill marked `compatible` cannot be parsed as Agent Skills markdown.
- The generated manifest differs from the checked-in
`generated/catalog.json`.
Trust level is derived from inventory:
- `scripts_executables` when any file is classified as `script`.
- `assets` when any file is classified as `asset` or `other` and no script is
present.
- `markdown_only` when all files are markdown, references, or `SKILL.md`.
Validation must report all discovered catalog errors when practical, not just
the first one.
## Catalog API Contract
Phase D adds read APIs and one company install API.
```text
GET /api/skills/catalog
GET /api/skills/catalog/:catalogId
GET /api/skills/catalog/:catalogId/files?path=SKILL.md
POST /api/companies/:companyId/skills/install-catalog
```
`GET /api/skills/catalog` accepts:
- `kind=bundled|optional`
- `category=<slug>`
- `q=<text>`
`catalogId` is the path-safe manifest `id`. The server should also support
resolution by `key` or unique `slug` where the ref is carried in a query or body,
but route parameters use `id` to avoid slash handling ambiguity.
Install request:
```ts
interface CompanySkillInstallCatalogRequest {
catalogSkillId: string;
slug?: string | null;
force?: boolean;
}
```
Install result:
```ts
interface CompanySkillInstallCatalogResult {
action: "created" | "updated" | "unchanged";
skill: CompanySkill;
catalogSkill: CatalogSkill;
warnings: string[];
}
```
Install behavior:
- Creates or updates a company skill with `sourceType="catalog"`.
- Uses catalog `key` as the company skill canonical key.
- Uses catalog `slug` unless `slug` is provided.
- Materializes the catalog files into a company-managed skill directory so
existing skill file reads continue to work.
- Stores provenance in metadata:
- `catalogId`
- `catalogKey`
- `catalogKind`
- `catalogCategory`
- `catalogPath`
- `packageName`
- `packageVersion`
- `originHash`
- `originVersion`
- `userModifiedAt`
- `updateHoldReason`
- Writes activity log entries for install and update.
- Returns `409` for duplicate slug/key conflicts that cannot be resolved safely.
- Returns `422` for invalid, incompatible, or hard-blocked catalog entries.
- `force` may replace a same-key catalog-managed skill. It must not bypass
company boundaries, permission checks, hard validation, or hard security
findings.
## Error Semantics
Use existing HTTP semantics:
- `400`: invalid CLI arguments, invalid query/body shape, or malformed refs.
- `401`: missing or invalid auth.
- `403`: authenticated principal lacks access or mutation permission.
- `404`: skill, catalog entry, agent, file, company, or source not found.
- `409`: ambiguous slug, duplicate key/slug, update conflict, or unsafe overwrite.
- `422`: semantic violation such as invalid skill content or unsupported source.
- `500`: unexpected server failure.
CLI messages should name the next useful correction, for example:
- `Skill slug "review" is ambiguous. Use an id or key.`
- `Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set a context profile.`
- `Catalog skill contains executable scripts and cannot be force-installed until security review semantics allow it.`
## Phase Acceptance Criteria
Phase A is complete when this contract is available in the repo and the issue
thread links it.
Phase B, CLI MVP:
- `paperclipai skills --help` exposes the Phase B command group.
- All Phase B commands work against existing company skills and agent skills
APIs without schema or server changes.
- Skill refs resolve by id, key, or unique slug.
- Human and JSON output are covered by focused CLI tests.
- `doc/CLI.md` documents company install vs agent desired sync vs runtime sync.
Phase C, catalog package:
- `packages/skills-catalog` is a workspace package.
- Build or validation regenerates `generated/catalog.json`.
- Validation covers frontmatter, id/key/slug uniqueness, directory shape, file
inventory, trust derivation, and stale generated output.
- Server and CLI can import the manifest without crawling arbitrary paths.
- Root `skills/` is not expanded with the app-shipped catalog.
Phase D, catalog APIs:
- Catalog list/detail/file APIs are read-only and covered by tests.
- Install-from-catalog creates auditable company-scoped skill records with
provenance metadata and materialized files.
- Company boundary and mutation permission checks match or exceed existing
company skill mutations.
- Duplicate and unsafe overwrite behavior is explicit and tested.
Phase E, catalog CLI:
- Operators can browse, search, inspect, and install app-shipped catalog skills.
- External source behavior remains routed through `skills import`.
- Output and errors follow the Phase B CLI conventions.
- Catalog install is clearly distinct from agent attach/sync in help and docs.
Phase F, update/reset/audit:
- Security review records decisions for origin hash, user modification detection,
reset, audit findings, and force behavior.
- Implementation follows the review or records explicit deferrals.
- Mutating reset/update actions are activity logged.
- Tests cover dangerous findings, force behavior, and unchanged/current states.
Phase G, adapter truth model:
- Adapter snapshots accurately report `unsupported`, `persistent`, or
`ephemeral`.
- Desired, missing, installed, stale, external, and required states are tested.
- External adapter plugins remain dynamically loaded. No hardcoded plugin imports
are added.
Phase H, UI:
- The existing Company Skills page is extended rather than replaced.
- UX guidance covers Company, Bundled, Optional, and External source views.
- Install preview shows source, trust, provenance, update state, and file
inventory.
- Agent attach/detach states are clear.
- Frontend handoff includes screenshots or equivalent browser evidence.
Phase I, initial skill content:
- Bundled and optional entries use the finalized frontmatter and category rules.
- Skill descriptions are specific enough for browse/search.
- No script-bearing skill lands without explicit security review evidence.
- Validation fixtures or tests cover representative content.
Phase J, QA and docs:
- QA validates CLI, catalog APIs, UI install, agent sync, portability, and adapter
snapshots against a dev instance.
- Blocking defects are linked as first-class issues.
- `doc/CLI.md`, `doc/DEVELOPING.md`, and skill workflow docs match shipped
behavior.
## Deferrals
- No cloud marketplace.
- No user-home tap registry.
- No hidden curator or autonomous catalog mutator.
- No normalized `agent_skills` table in the first release.
- No skill sets or bundles in the first release.
- No automatic install of every optional catalog skill.
- No replacement of company import/export as the portability path.
-17
View File
@@ -249,23 +249,6 @@ Make Paperclip skills discoverable to your agent runtime without writing to the
3. **Acceptable: env var** — point a skills path env var at the repo's `skills/` directory 3. **Acceptable: env var** — point a skills path env var at the repo's `skills/` directory
4. **Last resort: prompt injection** — include skill content in the prompt template 4. **Last resort: prompt injection** — include skill content in the prompt template
## Cross-run workspace persistence (no-remote-git contract)
The local execution-workspace cwd is the **only** persistence boundary across runs. No adapter may depend on a git remote for cross-run state.
The supported round-trip:
- **Per-run, on the remote side.** `prepareWorkspaceForSshExecution` (in `packages/adapter-utils/src/ssh.ts`) git-bundles the local worktree and ships it to the run's remote dir. No `git remote` is set anywhere; the bundle is the transport.
- **End-of-run, in the adapter's `finally` block.** The adapter invokes `restoreRemoteWorkspace` (e.g. claude-local's `execute.ts`), which calls `restoreWorkspaceFromSshExecution``exportGitWorkspaceFromSsh``integrateImportedGitHead`. Remote commits made during the run land back in the local Mac worktree with no `git push` and no remote configured.
The invariant adapters must preserve:
- **Never `git push`** from adapter or runtime code. Operator-supplied configuration may opt in, but the default contract is no remote operations.
- **Never assume a remote exists.** The local cwd is the source of truth between runs.
- **Surface restore failures.** A failed sync-back must propagate as a run-level error, not a silent warning. The heartbeat records a `workspace_finalize` row (`succeeded`/`failed`) around `adapter.execute` so dependent issues do not wake on a stale worktree.
The invariant is pinned by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`: it asserts `git remote` is empty before and after the round-trip and that a remote-only commit still lands locally via restore alone.
## Security ## Security
- Treat agent output as untrusted (parse defensively, never execute) - Treat agent output as untrusted (parse defensively, never execute)
-23
View File
@@ -63,29 +63,6 @@ pnpm paperclipai agent list
pnpm paperclipai agent get <agent-id> pnpm paperclipai agent get <agent-id>
``` ```
## Skills Commands
```sh
# Browse app-shipped catalog skills without changing company state
pnpm paperclipai skills browse [--kind bundled|optional] [--category software-development] [--query github]
pnpm paperclipai skills search "pull request" [--json]
# Inspect catalog metadata and file inventory before install
pnpm paperclipai skills inspect github-pr-workflow
# Install a catalog skill into the company skill library
# This does not attach the skill to any agent.
pnpm paperclipai skills install github-pr-workflow --company-id <company-id>
pnpm paperclipai skills install github-pr-workflow --as pr-flow --force --company-id <company-id>
# External sources still use import instead of catalog install
pnpm paperclipai skills import ./skills/my-skill --company-id <company-id>
pnpm paperclipai skills import owner/repo/path/to/skill --company-id <company-id>
# Attach desired company skills to an agent after install/import
pnpm paperclipai skills agent sync <agent-id> --skill github-pr-workflow --company-id <company-id>
```
## Approval Commands ## Approval Commands
```sh ```sh
@@ -64,17 +64,6 @@ Heartbeat still resolves a workspace for the run, but that is about code locatio
4. Heartbeat passes the resolved code workspace to the agent run. 4. Heartbeat passes the resolved code workspace to the agent run.
5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services. 5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services.
## Cross-run persistence (no-remote-git contract)
Code state moves between runs through the local execution-workspace cwd alone — not through a git remote.
- Each run's prepare step bundles the local worktree to the run's remote dir over ssh, with no `git remote` configured.
- The adapter's restore step at the end of the run writes any new remote commits back into the local worktree directly.
- Adapters must never `git push` from runtime code, and must never assume a remote exists.
- A failed restore is a run-level error and records `workspace_finalize=failed` on the execution workspace, which gates dependent issue wakes until the next successful finalize.
The invariant is enforced by the "no-remote-git contract" case in `packages/adapter-utils/src/ssh-fixture.test.ts`, which asserts a remote-only commit reaches the local worktree with no remote configured at any point.
## Current implementation guarantees ## Current implementation guarantees
With the current implementation: With the current implementation:
+1 -3
View File
@@ -35,14 +35,12 @@
"release:rollback": "./scripts/rollback-latest.sh", "release:rollback": "./scripts/rollback-latest.sh",
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs", "release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
"check:tokens": "node scripts/check-forbidden-tokens.mjs", "check:tokens": "node scripts/check-forbidden-tokens.mjs",
"check:no-git-push": "node scripts/check-no-git-push.mjs",
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
"docs:dev": "cd docs && npx mintlify dev", "docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs", "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
-37
View File
@@ -1,37 +0,0 @@
# @paperclipai/adapter-utils
Shared utilities for Paperclip adapters: process spawning, environment
injection, sandbox/SSH transport, workspace sync, and the round-trip helpers
that move code between the local execution-workspace cwd and wherever the
agent actually runs.
For the adapter-author guide see
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md)
and the in-repo notes at [`packages/adapters/AUTHORING.md`](../adapters/AUTHORING.md).
## No-remote-git contract
The local execution-workspace cwd is the only persistence boundary across
runs. No adapter may depend on a git remote for cross-run state.
Adapters that run the agent on a different host should use the SSH round-trip
helpers in [`src/ssh.ts`](./src/ssh.ts):
- `prepareWorkspaceForSshExecution({ spec, localDir, remoteDir })` — bundles
the local cwd (tracked files, dirty edits, untracked additions, and the git
history needed to reconstruct it) to `remoteDir` before the run starts. Runs
with no `git remote` configured.
- `restoreWorkspaceFromSshExecution({ spec, localDir, remoteDir, ... })`
syncs the remote cwd back into `localDir` after the run, including any new
commits the agent created. Also runs with no `git remote` configured.
`prepareRemoteManagedRuntime` in
[`src/remote-managed-runtime.ts`](./src/remote-managed-runtime.ts) wraps both
calls for adapters that want a per-run remote workspace and an automatic
`restoreWorkspace()` finally hook.
The invariant is pinned by the `no-remote-git contract` case in
[`src/ssh-fixture.test.ts`](./src/ssh-fixture.test.ts), which asserts that a
remote-only commit propagates to the local worktree through the
prepare → restore round-trip with no git remote configured at any point. Do
not regress that test.
@@ -6,8 +6,6 @@ import { describe, expect, it } from "vitest";
import { import {
applyPaperclipWorkspaceEnv, applyPaperclipWorkspaceEnv,
appendWithByteCap, appendWithByteCap,
buildPersistentSkillSnapshot,
buildRuntimeMountedSkillSnapshot,
buildInvocationEnvForLogs, buildInvocationEnvForLogs,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
materializePaperclipSkillCopy, materializePaperclipSkillCopy,
@@ -207,186 +205,6 @@ describe("materializePaperclipSkillCopy", () => {
}); });
}); });
describe("adapter skill snapshots", () => {
const requiredEntry = {
key: "paperclipai/paperclip/paperclip",
runtimeName: "paperclip",
source: "/runtime/paperclip",
required: true,
requiredReason: "Required for Paperclip heartbeats.",
};
const optionalEntry = {
key: "company/ascii-heart",
runtimeName: "ascii-heart",
source: "/runtime/ascii-heart",
};
it("reports runtime-mounted adapters as configured or missing without install state", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "codex_local",
availableEntries: [requiredEntry],
desiredSkills: [requiredEntry.key, "missing-skill"],
configuredDetail: "Mounted on next run.",
});
expect(snapshot).toMatchObject({
supported: true,
mode: "ephemeral",
desiredSkills: [requiredEntry.key, "missing-skill"],
});
expect(snapshot.entries).toEqual([
expect.objectContaining({
key: "missing-skill",
state: "missing",
origin: "external_unknown",
desired: true,
}),
expect.objectContaining({
key: requiredEntry.key,
state: "configured",
origin: "paperclip_required",
required: true,
detail: "Mounted on next run.",
}),
]);
});
it("reports source-missing company runtime skills without orphan warnings", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "codex_local",
availableEntries: [{
key: "company/example/reflection-coach",
runtimeName: "reflection-coach--abc123",
source: "/paperclip/skills/example/__runtime__/reflection-coach--abc123",
sourceStatus: "missing",
missingDetail: "Company skill exists, but its local source is missing.",
}],
desiredSkills: ["company/example/reflection-coach"],
configuredDetail: "Mounted on next run.",
});
expect(snapshot.warnings).toEqual([]);
expect(snapshot.entries).toEqual([
expect.objectContaining({
key: "company/example/reflection-coach",
state: "missing",
origin: "company_managed",
sourcePath: null,
detail: "Company skill exists, but its local source is missing.",
}),
]);
});
it("keeps unsupported runtime-mounted adapters in tracked-only state", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "acpx_local",
availableEntries: [requiredEntry],
desiredSkills: [requiredEntry.key],
configuredDetail: "Mounted on next run.",
mode: "unsupported",
unsupportedDetail: "Tracked only.",
});
expect(snapshot.supported).toBe(false);
expect(snapshot.mode).toBe("unsupported");
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: requiredEntry.key,
desired: true,
state: "available",
detail: "Tracked only.",
}));
});
it("can surface read-only external skills for runtime-mounted adapters", () => {
const snapshot = buildRuntimeMountedSkillSnapshot({
adapterType: "claude_local",
availableEntries: [requiredEntry],
desiredSkills: [requiredEntry.key],
configuredDetail: "Mounted on next run.",
externalInstalled: new Map([
["crack-python", { targetPath: "/home/me/.claude/skills/crack-python", kind: "directory" }],
]),
externalLocationLabel: "~/.claude/skills",
externalDetail: "Installed outside Paperclip management in the Claude skills home.",
});
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: "crack-python",
runtimeName: "crack-python",
state: "external",
managed: false,
origin: "user_installed",
locationLabel: "~/.claude/skills",
readOnly: true,
}));
});
it("reports persistent adapter installed, stale, external, and missing states", () => {
const snapshot = buildPersistentSkillSnapshot({
adapterType: "cursor",
availableEntries: [requiredEntry, optionalEntry],
desiredSkills: [requiredEntry.key, "missing-skill"],
installed: new Map([
["paperclip", { targetPath: "/runtime/paperclip", kind: "symlink" }],
["ascii-heart", { targetPath: "/other/ascii-heart", kind: "directory" }],
["old-managed", { targetPath: "/runtime/old-managed", kind: "symlink" }],
]),
skillsHome: "/home/me/.cursor/skills",
locationLabel: "~/.cursor/skills",
installedDetail: "Installed in the Cursor skills home.",
missingDetail: "Configured but not linked.",
externalConflictDetail: "Name occupied externally.",
externalDetail: "Installed outside Paperclip management.",
});
expect(snapshot.mode).toBe("persistent");
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: requiredEntry.key,
state: "installed",
managed: true,
origin: "paperclip_required",
}));
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: optionalEntry.key,
state: "external",
managed: false,
detail: "Installed outside Paperclip management.",
}));
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: "missing-skill",
state: "missing",
origin: "external_unknown",
}));
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: "old-managed",
state: "external",
origin: "user_installed",
}));
});
it("reports stale managed persistent skills when Paperclip owns an undesired available skill", () => {
const snapshot = buildPersistentSkillSnapshot({
adapterType: "cursor",
availableEntries: [optionalEntry],
desiredSkills: [],
installed: new Map([
["ascii-heart", { targetPath: "/runtime/ascii-heart", kind: "symlink" }],
]),
skillsHome: "/home/me/.cursor/skills",
missingDetail: "Configured but not linked.",
externalConflictDetail: "Name occupied externally.",
externalDetail: "Installed outside Paperclip management.",
});
expect(snapshot.entries).toContainEqual(expect.objectContaining({
key: optionalEntry.key,
desired: false,
state: "stale",
managed: true,
}));
});
});
describe("runChildProcess", () => { describe("runChildProcess", () => {
it("does not arm a timeout when timeoutSec is 0", async () => { it("does not arm a timeout when timeoutSec is 0", async () => {
const result = await runChildProcess( const result = await runChildProcess(
-177
View File
@@ -133,8 +133,6 @@ 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;
} }
@@ -163,22 +161,6 @@ 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("\\", "/");
} }
@@ -211,26 +193,6 @@ 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,
@@ -1419,120 +1381,6 @@ 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 {
@@ -1556,26 +1404,6 @@ 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;
@@ -1668,11 +1496,6 @@ 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
@@ -451,68 +451,6 @@ describe("ssh env-lab fixture", () => {
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n"); await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS); }, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("propagates remote commits to the local worktree with no git remote configured (no-remote-git contract)", async () => {
// Locks in the architectural contract documented in
// packages/adapter-utils/README.md and packages/adapters/AUTHORING.md:
// the local execution-workspace cwd is the only persistence boundary
// across runs. No adapter may depend on a git remote for cross-run state.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
// Assert there is no git remote configured before we begin, and verify
// that no point in the round-trip introduces one. `git remote` returns an
// empty string when no remotes exist (and exit code 0).
expect(await git(localRepo, ["remote"])).toBe("");
const started = await startSshEnvLabFixtureOrSkip(
statePath,
"no-remote-git contract test",
);
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const prepared = await prepareRemoteManagedRuntime({
spec,
runId: "run-no-remote",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
// Remote commit lands a deliverable that must show up locally via
// sync-back alone — no `git push`, no fetch from any origin.
await runSshCommand(
config,
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "deliverable\\n" > tracked.txt && git add tracked.txt && git commit -m "remote-only commit" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await prepared.restoreWorkspace();
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe(
"remote-only commit",
);
expect(await readFile(path.join(localRepo, "tracked.txt"), "utf8")).toBe(
"deliverable\n",
);
// Final assertion: still no git remote — restore did not silently add one.
expect(await git(localRepo, ["remote"])).toBe("");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("merges concurrent remote commits through the managed runtime restore path", async () => { it("merges concurrent remote commits through the managed runtime restore path", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir); cleanupDirs.push(rootDir);
-58
View File
@@ -1,58 +0,0 @@
# Adapter Authoring Notes
In-repo notes for adapter authors. The user-facing guide lives at
[`docs/adapters/creating-an-adapter.md`](../../docs/adapters/creating-an-adapter.md);
this file holds invariants that are easy to violate from inside the adapter
package itself.
## No-remote-git contract (cross-run persistence)
The local execution-workspace cwd is the only persistence boundary across
runs. No adapter may depend on a git remote for cross-run state.
Why: Paperclip resolves a local execution workspace (a worktree) for each
heartbeat. Code state is carried forward by syncing that local cwd to wherever
the agent actually runs — over ssh, into a sandbox, into a managed runtime —
and then syncing changes back when the run finishes. Treating a `git remote`
as the source of truth (`git push` from inside the agent, fetch on the next
wake) breaks dependent issues that are gated on the local worktree being
caught up, and breaks isolated execution workspaces that have no remote
configured at all.
How to apply:
- Never `git push` from adapter runtime code. Never assume the local worktree
has any `git remote` configured. If you need data from the previous run,
read it from the local cwd Paperclip handed you.
- If your adapter runs the agent on a different host (ssh, sandbox, remote
container), use the round-trip helpers in `@paperclipai/adapter-utils`:
[`prepareWorkspaceForSshExecution`](../adapter-utils/src/ssh.ts) bundles the
local cwd to the remote dir before the run, and
[`restoreWorkspaceFromSshExecution`](../adapter-utils/src/ssh.ts) syncs
remote-side changes (including new git commits) back into the local cwd
after the run. Both run with no `git remote` configured.
- If your adapter runs the agent locally, you can read and write the cwd
directly — same invariant applies: changes that future runs need must live
in the local cwd by the time `execute()` returns.
- A failed sync-back is a run-level error. The heartbeat records
`workspace_finalize=failed` on the execution workspace, which gates
dependent issue wakes until the next successful finalize. Do not swallow
restore errors.
The invariant is pinned by the `no-remote-git contract` case in
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../adapter-utils/src/ssh-fixture.test.ts),
which asserts that a remote-only commit propagates to the local worktree
through `prepareWorkspaceForSshExecution``restoreWorkspaceFromSshExecution`
with no git remote configured at any point.
A static check enforces the rule before runtime ever sees it:
[`scripts/check-no-git-push.mjs`](../../scripts/check-no-git-push.mjs) scans
adapter and runtime source (`packages/adapters/`, `packages/adapter-utils/`,
`server/src/`, `cli/src/`) and fails the `policy` CI job if any unapproved
`git push` invocation is added. If you are building an operator-configured
path that legitimately must push, add a
`// paperclip:allow-git-push: <reason>` comment on the line (or the line
above) so the opt-in shows up in code review.
For the architecture-level write-up of cross-run persistence, see
[`docs/guides/board-operator/execution-workspaces-and-runtime-services.md`](../../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#cross-run-persistence-no-remote-git-contract).
@@ -2,10 +2,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { import type {
AdapterSkillContext, AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot, AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils"; } from "@paperclipai/adapter-utils";
import { import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries, readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames, resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
@@ -35,7 +35,9 @@ function unsupportedDetail(): string {
async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> { async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const acpxAgent = normalizeAcpxSkillAgent(config); const acpxAgent = normalizeAcpxSkillAgent(config);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const supported = acpxAgent !== "custom"; const supported = acpxAgent !== "custom";
const warnings: string[] = supported const warnings: string[] = supported
? [] ? []
@@ -43,16 +45,53 @@ async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<
"Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.", "Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.",
]; ];
return buildRuntimeMountedSkillSnapshot({ const entries: AdapterSkillEntry[] = availableEntries.map((entry) => {
const desired = desiredSet.has(entry.key);
return {
key: entry.key,
runtimeName: entry.runtimeName,
desired,
managed: true,
state: desired ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desired ? (supported ? configuredDetail(acpxAgent) : unsupportedDetail()) : null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
};
});
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",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "acpx_local", adapterType: "acpx_local",
availableEntries,
desiredSkills,
supported, supported,
mode: supported ? "ephemeral" : "unsupported", mode: supported ? "ephemeral" : "unsupported",
configuredDetail: configuredDetail(acpxAgent), desiredSkills,
unsupportedDetail: unsupportedDetail(), entries,
warnings, warnings,
}); };
} }
export async function listAcpxSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> { export async function listAcpxSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
@@ -6,7 +6,6 @@ export const label = "Claude Code (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
export const models = [ export const models = [
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" }, { id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
@@ -1,6 +1,6 @@
export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js"; export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js";
export { listClaudeSkills, syncClaudeSkills } from "./skills.js"; export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js"; export { listClaudeModels } from "./models.js";
export { testEnvironment } from "./test.js"; export { testEnvironment } from "./test.js";
export { export {
parseClaudeStreamJson, parseClaudeStreamJson,
@@ -1,22 +1,13 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@paperclipai/adapter-utils"; import type { AdapterModel } from "@paperclipai/adapter-utils";
import { models as DIRECT_MODELS } from "../index.js"; import { models as DIRECT_MODELS } from "../index.js";
const ANTHROPIC_MODELS_ENDPOINT = "/v1/models";
const ANTHROPIC_MODELS_TIMEOUT_MS = 5000;
const ANTHROPIC_MODELS_CACHE_TTL_MS = 60_000;
const ANTHROPIC_API_VERSION = "2023-06-01";
/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */ /** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */
const BEDROCK_MODELS: AdapterModel[] = [ const BEDROCK_MODELS: AdapterModel[] = [
{ id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" },
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" }, { id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" }, { id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" }, { id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
]; ];
let cached: { keyFingerprint: string; baseUrl: string; expiresAt: number; models: AdapterModel[] } | null = null;
function isBedrockEnv(): boolean { function isBedrockEnv(): boolean {
return ( return (
process.env.CLAUDE_CODE_USE_BEDROCK === "1" || process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
@@ -26,134 +17,13 @@ function isBedrockEnv(): boolean {
); );
} }
function fingerprint(apiKey: string): string {
const digest = createHash("sha256").update(apiKey).digest("base64url").slice(0, 16);
return `${apiKey.length}:${digest}`;
}
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function mergedWithFallback(models: AdapterModel[]): AdapterModel[] {
return dedupeModels([
...models,
...DIRECT_MODELS,
]);
}
function resolveAnthropicApiKey(): string | null {
const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
return apiKey && apiKey.length > 0 ? apiKey : null;
}
function resolveAnthropicBaseUrl(): string {
const baseUrl = process.env.ANTHROPIC_BASE_URL?.trim();
return baseUrl && baseUrl.length > 0 ? baseUrl.replace(/\/+$/, "") : "https://api.anthropic.com";
}
async function fetchAnthropicModels(apiKey: string, baseUrl: string): Promise<AdapterModel[]> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ANTHROPIC_MODELS_TIMEOUT_MS);
try {
const response = await fetch(`${baseUrl}${ANTHROPIC_MODELS_ENDPOINT}`, {
headers: {
"anthropic-version": ANTHROPIC_API_VERSION,
"x-api-key": apiKey,
},
signal: controller.signal,
});
if (!response.ok) return [];
const payload = (await response.json()) as { data?: unknown };
const data = Array.isArray(payload.data) ? payload.data : [];
const models: AdapterModel[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null) continue;
const record = item as { id?: unknown; display_name?: unknown };
if (typeof record.id !== "string" || record.id.trim().length === 0) continue;
const displayName =
typeof record.display_name === "string" && record.display_name.trim().length > 0
? record.display_name
: record.id;
models.push({
id: record.id,
label: displayName,
});
}
return dedupeModels(models);
} catch (error) {
console.warn("[paperclip] Claude model discovery failed", {
error: error instanceof Error ? error.message : String(error),
});
return [];
} finally {
clearTimeout(timeout);
}
}
async function loadClaudeModels(options?: { forceRefresh?: boolean }): Promise<AdapterModel[]> {
if (isBedrockEnv()) return dedupeModels(BEDROCK_MODELS);
const fallback = dedupeModels(DIRECT_MODELS);
const apiKey = resolveAnthropicApiKey();
if (!apiKey) return fallback;
const now = Date.now();
const baseUrl = resolveAnthropicBaseUrl();
const keyFingerprint = fingerprint(apiKey);
if (
options?.forceRefresh !== true &&
cached &&
cached.keyFingerprint === keyFingerprint &&
cached.baseUrl === baseUrl &&
cached.expiresAt > now
) {
return cached.models;
}
const fetched = await fetchAnthropicModels(apiKey, baseUrl);
if (fetched.length > 0) {
const merged = mergedWithFallback(fetched);
cached = {
keyFingerprint,
baseUrl,
expiresAt: now + ANTHROPIC_MODELS_CACHE_TTL_MS,
models: merged,
};
return merged;
}
if (cached && cached.keyFingerprint === keyFingerprint && cached.baseUrl === baseUrl && cached.models.length > 0) {
return cached.models;
}
return fallback;
}
/** /**
* Return the model list appropriate for the current auth mode. * Return the model list appropriate for the current auth mode.
* When Bedrock env vars are detected, returns Bedrock-native model IDs; * When Bedrock env vars are detected, returns Bedrock-native model IDs;
* otherwise returns standard Anthropic API model IDs. * otherwise returns standard Anthropic API model IDs.
*/ */
export async function listClaudeModels(): Promise<AdapterModel[]> { export async function listClaudeModels(): Promise<AdapterModel[]> {
return loadClaudeModels(); return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS;
}
export async function refreshClaudeModels(): Promise<AdapterModel[]> {
return loadClaudeModels({ forceRefresh: true });
}
export function resetClaudeModelsCacheForTests() {
cached = null;
} }
/** Check whether a model ID is a Bedrock-native identifier (not an Anthropic API short name). */ /** Check whether a model ID is a Bedrock-native identifier (not an Anthropic API short name). */
@@ -3,10 +3,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { import type {
AdapterSkillContext, AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot, AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils"; } from "@paperclipai/adapter-utils";
import { import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries, readPaperclipRuntimeSkillEntries,
readInstalledSkillTargets, readInstalledSkillTargets,
resolvePaperclipDesiredSkillNames, resolvePaperclipDesiredSkillNames,
@@ -30,19 +30,76 @@ function resolveClaudeSkillsHome(config: Record<string, unknown>) {
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> { async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveClaudeSkillsHome(config); const skillsHome = resolveClaudeSkillsHome(config);
const installed = await readInstalledSkillTargets(skillsHome); const installed = await readInstalledSkillTargets(skillsHome);
return buildRuntimeMountedSkillSnapshot({ const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
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",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: undefined,
targetPath: undefined,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.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: "~/.claude/skills",
readOnly: true,
sourcePath: null,
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
detail: "Installed outside Paperclip management in the Claude skills home.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "claude_local", adapterType: "claude_local",
availableEntries, supported: true,
mode: "ephemeral",
desiredSkills, desiredSkills,
configuredDetail: "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run.", entries,
externalInstalled: installed, warnings,
externalLocationLabel: "~/.claude/skills", };
externalDetail: "Installed outside Paperclip management in the Claude skills home.",
skillsHome,
});
} }
export async function listClaudeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> { export async function listClaudeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
@@ -2,10 +2,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { import type {
AdapterSkillContext, AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot, AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils"; } from "@paperclipai/adapter-utils";
import { import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries, readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames, resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
@@ -16,13 +16,56 @@ async function buildCodexSkillSnapshot(
config: Record<string, unknown>, config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> { ): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
return buildRuntimeMountedSkillSnapshot({ const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be linked into the effective CODEX_HOME/skills/ directory on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
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",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "codex_local", adapterType: "codex_local",
availableEntries, supported: true,
mode: "ephemeral",
desiredSkills, desiredSkills,
configuredDetail: "Will be linked into the effective CODEX_HOME/skills/ directory on the next run.", entries,
}); warnings,
};
} }
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> { export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
@@ -2,10 +2,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { import type {
AdapterSkillContext, AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot, AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils"; } from "@paperclipai/adapter-utils";
import { import {
buildRuntimeMountedSkillSnapshot,
readPaperclipRuntimeSkillEntries, readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames, resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
@@ -16,13 +16,56 @@ async function buildGrokSkillSnapshot(
config: Record<string, unknown>, config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> { ): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
return buildRuntimeMountedSkillSnapshot({ const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be copied into `.claude/skills` in the execution workspace on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
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",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "grok_local", adapterType: "grok_local",
availableEntries, supported: true,
mode: "ephemeral",
desiredSkills, desiredSkills,
configuredDetail: "Will be copied into `.claude/skills` in the execution workspace on the next run.", entries,
}); warnings,
};
} }
export async function listGrokSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> { export async function listGrokSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
@@ -70,16 +70,3 @@ Structured gateway event logs use:
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames - `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
UI/CLI parsers consume these lines to render transcript updates. UI/CLI parsers consume these lines to render transcript updates.
## No-remote-git contract
Like every Paperclip adapter, this one must treat the local execution-workspace
cwd as the only persistence boundary across runs — no `git push` from runtime
code, no assuming a `git remote` exists. The gateway transport here doesn't
touch the workspace directly, but if you extend the adapter to ship code to
the OpenClaw side, use the round-trip helpers in `@paperclipai/adapter-utils`
(`prepareWorkspaceForSshExecution``restoreWorkspaceFromSshExecution`)
rather than reaching for a git remote. See
[`packages/adapters/AUTHORING.md`](../AUTHORING.md#no-remote-git-contract-cross-run-persistence)
for the full contract and the pinning test at
[`packages/adapter-utils/src/ssh-fixture.test.ts`](../../adapter-utils/src/ssh-fixture.test.ts).
@@ -1,189 +0,0 @@
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");
@@ -1,28 +0,0 @@
CREATE TABLE "issue_plan_decompositions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"source_issue_id" uuid NOT NULL,
"accepted_plan_revision_id" uuid NOT NULL,
"accepted_interaction_id" uuid,
"status" text DEFAULT 'in_flight' NOT NULL,
"request_fingerprint" text NOT NULL,
"requested_child_count" integer DEFAULT 0 NOT NULL,
"requested_children" jsonb DEFAULT '[]'::jsonb NOT NULL,
"child_issue_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
"owner_agent_id" uuid,
"owner_user_id" text,
"owner_run_id" uuid,
"completed_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
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk" FOREIGN KEY ("accepted_plan_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk" FOREIGN KEY ("accepted_interaction_id") REFERENCES "public"."issue_thread_interactions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("owner_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_plan_decompositions_company_source_status_idx" ON "issue_plan_decompositions" USING btree ("company_id","source_issue_id","status");--> statement-breakpoint
CREATE INDEX "issue_plan_decompositions_active_owner_idx" ON "issue_plan_decompositions" USING btree ("company_id","owner_agent_id") WHERE "issue_plan_decompositions"."status" = 'in_flight';--> statement-breakpoint
CREATE UNIQUE INDEX "issue_plan_decompositions_source_revision_uq" ON "issue_plan_decompositions" USING btree ("company_id","source_issue_id","accepted_plan_revision_id");
@@ -1,6 +0,0 @@
ALTER TABLE "execution_workspaces" DROP CONSTRAINT "execution_workspaces_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_operations" DROP CONSTRAINT "workspace_operations_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -22
View File
@@ -638,27 +638,6 @@
"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
},
{
"idx": 92,
"version": "7",
"when": 1779999768200,
"tag": "0092_mighty_puma",
"breakpoints": true
},
{
"idx": 93,
"version": "7",
"when": 1780040470886,
"tag": "0093_giant_green_goblin",
"breakpoints": true
} }
] ]
} }
@@ -1,42 +0,0 @@
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,
),
}),
);
@@ -1,44 +0,0 @@
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")),
}),
);
@@ -1,70 +0,0 @@
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,
),
}),
);
@@ -16,7 +16,7 @@ export const executionWorkspaces = pgTable(
"execution_workspaces", "execution_workspaces",
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }), projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }), sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }),
-4
View File
@@ -32,7 +32,6 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js"; export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js"; export { goals } from "./goals.js";
export { issues } from "./issues.js"; export { issues } from "./issues.js";
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
export { issueRecoveryActions } from "./issue_recovery_actions.js"; export { issueRecoveryActions } from "./issue_recovery_actions.js";
export { issueReferenceMentions } from "./issue_reference_mentions.js"; export { issueReferenceMentions } from "./issue_reference_mentions.js";
export { issueRelations } from "./issue_relations.js"; export { issueRelations } from "./issue_relations.js";
@@ -56,9 +55,6 @@ 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";
@@ -1,48 +0,0 @@
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, integer, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentRevisions } from "./document_revisions.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issueThreadInteractions } from "./issue_thread_interactions.js";
import { issues } from "./issues.js";
export const issuePlanDecompositions = pgTable(
"issue_plan_decompositions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
acceptedPlanRevisionId: uuid("accepted_plan_revision_id")
.notNull()
.references(() => documentRevisions.id, { onDelete: "cascade" }),
acceptedInteractionId: uuid("accepted_interaction_id")
.references(() => issueThreadInteractions.id, { onDelete: "set null" }),
status: text("status").notNull().default("in_flight"),
requestFingerprint: text("request_fingerprint").notNull(),
requestedChildCount: integer("requested_child_count").notNull().default(0),
requestedChildren: jsonb("requested_children").$type<Record<string, unknown>[]>().notNull().default(sql`'[]'::jsonb`),
childIssueIds: jsonb("child_issue_ids").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
ownerUserId: text("owner_user_id"),
ownerRunId: uuid("owner_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySourceStatusIdx: index("issue_plan_decompositions_company_source_status_idx").on(
table.companyId,
table.sourceIssueId,
table.status,
),
activeOwnerIdx: index("issue_plan_decompositions_active_owner_idx")
.on(table.companyId, table.ownerAgentId)
.where(sql`${table.status} = 'in_flight'`),
sourceRevisionUq: uniqueIndex("issue_plan_decompositions_source_revision_uq").on(
table.companyId,
table.sourceIssueId,
table.acceptedPlanRevisionId,
),
}),
);
@@ -17,7 +17,7 @@ export const workspaceOperations = pgTable(
"workspace_operations", "workspace_operations",
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), companyId: uuid("company_id").notNull().references(() => companies.id),
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, { executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, {
onDelete: "set null", onDelete: "set null",
}), }),
@@ -34,7 +34,7 @@ Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `work
Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly: Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly:
```bash ```bash
node packages/plugins/create-paperclip-plugin/dist/bin.js @acme/my-plugin \ node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
--output /absolute/path/to/plugins \ --output /absolute/path/to/plugins \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk --sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
``` ```
@@ -13,7 +13,7 @@
}, },
"type": "module", "type": "module",
"bin": { "bin": {
"create-paperclip-plugin": "./dist/bin.js" "create-paperclip-plugin": "./dist/index.js"
}, },
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
@@ -21,7 +21,7 @@
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"bin": { "bin": {
"create-paperclip-plugin": "./dist/bin.js" "create-paperclip-plugin": "./dist/index.js"
}, },
"exports": { "exports": {
".": { ".": {
@@ -38,7 +38,6 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"test": "pnpm -w exec vitest run --root packages/plugins/create-paperclip-plugin --config vitest.config.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -1,62 +0,0 @@
#!/usr/bin/env node
import path from "node:path";
import { pathToFileURL } from "node:url";
import { scaffoldPluginProject, type ScaffoldPluginOptions } from "./index.js";
interface RunCliDeps {
cwd?: string;
stdout?: (message: string) => void;
stderr?: (message: string) => void;
exit?: (code: number) => never;
}
function parseArg(argv: string[], name: string): string | undefined {
const index = argv.indexOf(name);
if (index === -1) return undefined;
return argv[index + 1];
}
/** Convert `@scope/name` to an output directory basename (`name`). */
function packageToDirName(pluginName: string): string {
return pluginName.replace(/^@[^/]+\//, "");
}
/** CLI wrapper for `scaffoldPluginProject`. */
export function runCli(argv = process.argv, deps: RunCliDeps = {}): string | undefined {
const pluginName = argv[2];
const stderr = deps.stderr ?? console.error;
const stdout = deps.stdout ?? console.log;
const exit = deps.exit ?? process.exit;
if (!pluginName) {
stderr("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
exit(1);
}
const template = (parseArg(argv, "--template") ?? "default") as ScaffoldPluginOptions["template"];
const outputRoot = parseArg(argv, "--output") ?? deps.cwd ?? process.cwd();
const targetDir = path.resolve(outputRoot, packageToDirName(pluginName));
const out = scaffoldPluginProject({
pluginName,
outputDir: targetDir,
template,
displayName: parseArg(argv, "--display-name"),
description: parseArg(argv, "--description"),
author: parseArg(argv, "--author"),
category: parseArg(argv, "--category") as ScaffoldPluginOptions["category"] | undefined,
sdkPath: parseArg(argv, "--sdk-path"),
});
stdout(`Created plugin scaffold at ${out}`);
return out;
}
function isMainModule(): boolean {
const entrypoint = process.argv[1];
return entrypoint ? import.meta.url === pathToFileURL(entrypoint).href : false;
}
if (isMainModule()) {
runCli();
}
@@ -1,74 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(process.cwd(), ".tmp-create-paperclip-plugin-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("create-paperclip-plugin entrypoints", () => {
it("keeps src/index.ts import-safe when process.argv points at another bundled CLI", async () => {
const originalArgv = process.argv;
const outputRoot = makeTempDir();
try {
process.argv = [process.execPath, path.resolve("cli/dist/index.js"), "demo-plugin", "--output", outputRoot];
const library = await import("./index.js");
expect(library.scaffoldPluginProject).toBeTypeOf("function");
expect(fs.existsSync(path.join(outputRoot, "demo-plugin"))).toBe(false);
} finally {
process.argv = originalArgv;
}
});
it("runs scaffolding from src/bin.ts", async () => {
const { runCli } = await import("./bin.js");
const outputRoot = makeTempDir();
const stdout: string[] = [];
const outputDir = path.join(outputRoot, "demo-plugin");
const result = runCli(
[
process.execPath,
"create-paperclip-plugin",
"demo-plugin",
"--output",
outputRoot,
"--sdk-path",
path.resolve("packages/plugins/sdk"),
],
{
stdout: (message) => stdout.push(message),
stderr: (message) => {
throw new Error(message);
},
exit: (code) => {
throw new Error(`unexpected exit ${code}`);
},
},
);
expect(result).toBe(outputDir);
expect(stdout).toEqual([`Created plugin scaffold at ${outputDir}`]);
expect(JSON.parse(fs.readFileSync(path.join(outputDir, "package.json"), "utf8"))).toMatchObject({
name: "demo-plugin",
paperclipPlugin: {
manifest: "./dist/manifest.js",
worker: "./dist/worker.js",
ui: "./dist/ui/",
},
});
});
});
@@ -1,3 +1,4 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
@@ -698,3 +699,41 @@ paperclipai plugin install ${shellQuote(toPosixPath(outputDir))}
return outputDir; return outputDir;
} }
function parseArg(name: string): string | undefined {
const index = process.argv.indexOf(name);
if (index === -1) return undefined;
return process.argv[index + 1];
}
/** CLI wrapper for `scaffoldPluginProject`. */
function runCli() {
const pluginName = process.argv[2];
if (!pluginName) {
// eslint-disable-next-line no-console
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
process.exit(1);
}
const template = (parseArg("--template") ?? "default") as PluginTemplate;
const outputRoot = parseArg("--output") ?? process.cwd();
const targetDir = path.resolve(outputRoot, packageToDirName(pluginName));
const out = scaffoldPluginProject({
pluginName,
outputDir: targetDir,
template,
displayName: parseArg("--display-name"),
description: parseArg("--description"),
author: parseArg("--author"),
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
sdkPath: parseArg("--sdk-path"),
});
// eslint-disable-next-line no-console
console.log(`Created plugin scaffold at ${out}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
runCli();
}
@@ -5,6 +5,5 @@
"rootDir": "src", "rootDir": "src",
"types": ["node"] "types": ["node"]
}, },
"include": ["src"], "include": ["src"]
"exclude": ["src/**/*.test.ts"]
} }
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
@@ -1,6 +1,6 @@
{ {
"name": "@paperclipai/plugin-exe-dev", "name": "@paperclipai/plugin-exe-dev",
"version": "0.1.1", "version": "0.1.0",
"description": "exe.dev sandbox provider plugin for Paperclip environments", "description": "exe.dev sandbox provider plugin for Paperclip environments",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip", "homepage": "https://github.com/paperclipai/paperclip",
@@ -1,7 +1,7 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.exe-dev-sandbox-provider"; const PLUGIN_ID = "paperclip.exe-dev-sandbox-provider";
const PLUGIN_VERSION = "0.1.1"; const PLUGIN_VERSION = "0.1.0";
const manifest: PaperclipPluginManifestV1 = { const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID, id: PLUGIN_ID,
@@ -26,150 +26,106 @@ const manifest: PaperclipPluginManifestV1 = {
configSchema: { configSchema: {
type: "object", type: "object",
properties: { properties: {
// ---- Essentials (always visible, in this order) ----
apiKey: { apiKey: {
type: "string", type: "string",
format: "secret-ref", format: "secret-ref",
description: description:
"Paste your exe.dev API token, or pick a saved Paperclip secret. Create one at exe.dev → Settings → API tokens with `/exec` scope (`new`, `ls`, `rm`).", "Environment-specific exe.dev API token. Needs `/exec` permission for at least `new`, `ls`, and `rm`. Paste a token or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Falls back to EXE_API_KEY if omitted.",
}, },
sshPrivateKey: { apiUrl: {
type: "string",
format: "secret-ref",
maxLength: 8192,
description:
"Paste the SSH private key you registered with exe.dev, or pick a saved secret. Leave blank to fall back to an on-host key (see Advanced → SSH access).",
},
// ---- Advanced: SSH access ----
sshUser: {
type: "string", type: "string",
description: description:
"Login user on the VM. Leave blank to use the image default, usually `root`.", "Optional exe.dev HTTPS API base URL or /exec endpoint. Defaults to https://exe.dev/exec.",
"x-paperclip-advanced": true,
"x-paperclip-group": "SSH access",
},
sshIdentityFile: {
type: "string",
description:
"Absolute path to a private key on the Paperclip host. Used only when SSH Private Key is empty.",
"x-paperclip-advanced": true,
"x-paperclip-group": "SSH access",
},
sshPort: {
type: "number",
description: "SSH port for direct VM access.",
default: 22,
"x-paperclip-advanced": true,
"x-paperclip-group": "SSH access",
},
strictHostKeyChecking: {
type: "string",
description:
"Host key policy passed to ssh via StrictHostKeyChecking. Typical values are `accept-new`, `yes`, or `no`.",
default: "accept-new",
"x-paperclip-advanced": true,
"x-paperclip-group": "SSH access",
},
// ---- Advanced: VM resources ----
image: {
type: "string",
description: "Optional container image to use when creating the VM.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM resources",
},
cpu: {
type: "number",
description: "Optional CPU count passed to `exe.dev new --cpu`.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM resources",
},
memory: {
type: "string",
description: "Optional memory size such as `4GB`.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM resources",
},
disk: {
type: "string",
description: "Optional disk size such as `20GB`.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM resources",
},
// ---- Advanced: VM creation ----
command: {
type: "string",
description: "Optional container command passed to `exe.dev new --command`.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
},
env: {
type: "object",
description: "Optional environment variables applied at VM creation time.",
additionalProperties: { type: "string" },
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
},
integrations: {
type: "array",
description: "Optional exe.dev integrations to attach during VM creation.",
items: { type: "string" },
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
},
tags: {
type: "array",
description: "Optional tags to apply during VM creation.",
items: { type: "string" },
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
},
setupScript: {
type: "string",
description: "Optional first-boot setup script passed to `exe.dev new --setup-script`.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
},
prompt: {
type: "string",
description: "Optional Shelley prompt passed to `exe.dev new --prompt`.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
},
comment: {
type: "string",
description: "Optional short note attached to created VMs.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
}, },
namePrefix: { namePrefix: {
type: "string", type: "string",
description: "Optional prefix used when generating VM names.", description: "Optional prefix used when generating VM names.",
default: "paperclip", default: "paperclip",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
}, },
// ---- Advanced: API + runtime ---- image: {
apiUrl: {
type: "string", type: "string",
description: description: "Optional container image to use when creating the VM.",
"Optional exe.dev HTTPS API base URL or /exec endpoint. Defaults to https://exe.dev/exec.", },
"x-paperclip-advanced": true, command: {
"x-paperclip-group": "API + runtime", type: "string",
description: "Optional container command passed to `exe.dev new --command`.",
},
cpu: {
type: "number",
description: "Optional CPU count passed to `exe.dev new --cpu`.",
},
memory: {
type: "string",
description: "Optional memory size such as `4GB`.",
},
disk: {
type: "string",
description: "Optional disk size such as `20GB`.",
},
comment: {
type: "string",
description: "Optional short note attached to created VMs.",
},
env: {
type: "object",
description: "Optional environment variables applied at VM creation time.",
additionalProperties: { type: "string" },
},
integrations: {
type: "array",
description: "Optional exe.dev integrations to attach during VM creation.",
items: { type: "string" },
},
tags: {
type: "array",
description: "Optional tags to apply during VM creation.",
items: { type: "string" },
},
setupScript: {
type: "string",
description: "Optional first-boot setup script passed to `exe.dev new --setup-script`.",
},
prompt: {
type: "string",
description: "Optional Shelley prompt passed to `exe.dev new --prompt`.",
}, },
timeoutMs: { timeoutMs: {
type: "number", type: "number",
description: "Timeout for VM lifecycle and SSH operations in milliseconds.", description: "Timeout for VM lifecycle and SSH operations in milliseconds.",
default: 300000, default: 300000,
"x-paperclip-advanced": true,
"x-paperclip-group": "API + runtime",
}, },
reuseLease: { reuseLease: {
type: "boolean", type: "boolean",
description: description:
"Whether to keep the VM alive between runs instead of deleting it on release.", "Whether to keep the VM alive between runs instead of deleting it on release.",
default: false, default: false,
"x-paperclip-advanced": true, },
"x-paperclip-group": "API + runtime", sshUser: {
type: "string",
description: "Optional SSH username for direct VM access.",
},
sshPrivateKey: {
type: "string",
format: "secret-ref",
maxLength: 4096,
description:
"Optional exe.dev-registered SSH private key. Paste the private key or an existing Paperclip secret reference; saved environments store pasted values as company secrets. If omitted, Paperclip falls back to sshIdentityFile, then the host's default SSH agent/keychain.",
},
sshIdentityFile: {
type: "string",
description:
"Optional absolute path to the SSH private key the Paperclip host should use for VM access when sshPrivateKey is omitted. Leave both blank to rely on the host's default SSH agent/keychain.",
},
sshPort: {
type: "number",
description: "SSH port for direct VM access.",
default: 22,
},
strictHostKeyChecking: {
type: "string",
description:
"Host key policy passed to ssh via StrictHostKeyChecking. Typical values are `accept-new`, `yes`, or `no`.",
default: "accept-new",
}, },
}, },
}, },
@@ -14,7 +14,7 @@ vi.mock("node:child_process", async () => {
}; };
}); });
import plugin, { validateSshPrivateKey } from "./plugin.js"; import plugin from "./plugin.js";
class MockChildProcess extends EventEmitter { class MockChildProcess extends EventEmitter {
stdout = new EventEmitter(); stdout = new EventEmitter();
@@ -165,117 +165,6 @@ describe("exe.dev sandbox provider plugin", () => {
}); });
}); });
describe("sshPrivateKey validation", () => {
const VALID_OPENSSH = [
"-----BEGIN OPENSSH PRIVATE KEY-----",
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt",
"ZWQyNTUxOQAAACBPzMxQp4Y6XCfDV2t6oWmqHkKx0K7C7w7q9F6gQ3jPbgAAAJjJ8jjE",
"yfI4xAAAAAtzc2gtZWQyNTUxOQAAACBPzMxQp4Y6XCfDV2t6oWmqHkKx0K7C7w7q9F6g",
"Q3jPbgAAAEDqLhB4kV1tw8m4gE9oNCkF2cJv0YnHQ8E5sHU3xKnD5k/MzFCnhjpcJ8NX",
"a3qhaaoeQrHQrsLvDur0XqBDeM9uAAAAFXVzZXJAaG9zdAECAwQ=",
"-----END OPENSSH PRIVATE KEY-----",
].join("\n");
const VALID_RSA_PEM = [
"-----BEGIN RSA PRIVATE KEY-----",
"MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu",
"KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm",
"o3qGy0t6z5tZbcgvflRslzu1HxXLpwYqQq2gMNw9UQAoHs3rDl+EzBjF6trBV5wF",
"wQIhANwiwDR7TVlIRk5kbgPMd2dDgY8mAU1cQ8KbWvjVMmKxAiEAxYTUyVjwhfQy",
"VJoR7T0n4XdR1n+W8Eth7AEPxnHfaQECIB5cNuqB9F1qC2pSyf6e+UAyl9rmKQXp",
"-----END RSA PRIVATE KEY-----",
].join("\n");
it("accepts a valid OpenSSH PEM block", () => {
expect(validateSshPrivateKey(VALID_OPENSSH)).toBeNull();
});
it("accepts a valid PKCS#1 RSA PEM block", () => {
expect(validateSshPrivateKey(VALID_RSA_PEM)).toBeNull();
});
it("accepts UUID-like secret reference values from the save-time schema stage", async () => {
process.env.EXE_API_KEY = "host-key";
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "exe-dev",
config: {
apiKey: "api-key",
sshPrivateKey: "11111111-1111-4111-8111-111111111111",
},
});
expect(result).toMatchObject({
ok: true,
normalizedConfig: {
sshPrivateKey: "11111111-1111-4111-8111-111111111111",
},
});
expect(result?.errors ?? []).toEqual([]);
});
it("treats empty / whitespace-only input as valid (falls back to on-host key)", () => {
expect(validateSshPrivateKey("")).toBeNull();
expect(validateSshPrivateKey(" \n\n ")).toBeNull();
});
it("rejects a pasted public key", () => {
expect(
validateSshPrivateKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE+gT9 user@host"),
).toMatch(/looks like a PUBLIC key/);
});
it("rejects a PuTTY PPK file paste", () => {
const ppk = [
"PuTTY-User-Key-File-3: ssh-ed25519",
"Encryption: none",
"Comment: imported-openssh-key",
"Public-Lines: 2",
"AAAAC3NzaC1lZDI1NTE5AAAAIE+gT9zMxQp4Y6XCfDV2t6oWmqHkKx0K7C7w7q9F6g",
"Q3jP",
].join("\n");
expect(validateSshPrivateKey(ppk)).toMatch(/PuTTY \.ppk/);
});
it("rejects a missing END marker (truncated paste)", () => {
const truncated = VALID_OPENSSH.split("\n").slice(0, -1).join("\n");
expect(validateSshPrivateKey(truncated)).toMatch(/missing its '-----END/);
});
it("rejects a body with non-base64 characters", () => {
const garbled = [
"-----BEGIN OPENSSH PRIVATE KEY-----",
"this is not base64!!",
"-----END OPENSSH PRIVATE KEY-----",
].join("\n");
expect(validateSshPrivateKey(garbled)).toMatch(/non-base64/);
});
it("rejects a header/footer label mismatch", () => {
const mismatched = [
"-----BEGIN OPENSSH PRIVATE KEY-----",
"Zm9vYmFy",
"-----END RSA PRIVATE KEY-----",
].join("\n");
expect(validateSshPrivateKey(mismatched)).toMatch(/header\/footer mismatch/);
});
it("returns the sshPrivateKey error from onEnvironmentValidateConfig on save", async () => {
process.env.EXE_API_KEY = "host-key";
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "exe-dev",
config: {
sshPrivateKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE+gT9 user@host",
},
});
expect(result?.ok).toBe(false);
expect(result?.errors ?? []).toEqual(
expect.arrayContaining([expect.stringMatching(/sshPrivateKey looks like a PUBLIC key/)]),
);
});
});
it("acquires a lease by creating a VM and preparing the SSH workspace", async () => { it("acquires a lease by creating a VM and preparing the SSH workspace", async () => {
fetchMock.mockResolvedValueOnce( fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ new Response(JSON.stringify({
@@ -457,38 +346,6 @@ describe("exe.dev sandbox provider plugin", () => {
expect(String(fetchMock.mock.calls[1]?.[1]?.body ?? "")).toBe("rm --json 'paperclip-env-run'"); expect(String(fetchMock.mock.calls[1]?.[1]?.body ?? "")).toBe("rm --json 'paperclip-env-run'");
}); });
it("surfaces invalid SSH key-format guidance during lease acquisition", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({
vm_name: "paperclip-env-run",
ssh_dest: "paperclip-env-run.exe.xyz",
https_url: "https://paperclip-env-run.exe.xyz",
status: "running",
}), { status: 200 }),
);
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
queueSpawnResult({
code: 255,
stderr: 'Load key "/tmp/paperclip-exe-dev-ssh-abc/id_ed25519": invalid format\n',
});
await expect(plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "exe-dev",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
config: {
apiKey: "api-key",
sshPrivateKey: "not-actually-a-key",
timeoutMs: 300000,
},
})).rejects.toThrow(
"the configured SSH private key isn't an OpenSSH-format private key",
);
expect(String(fetchMock.mock.calls[1]?.[1]?.body ?? "")).toBe("rm --json 'paperclip-env-run'");
});
it("redacts sensitive lifecycle flags in API errors", async () => { it("redacts sensitive lifecycle flags in API errors", async () => {
fetchMock.mockResolvedValueOnce(new Response("upstream boom", { status: 500 })); fetchMock.mockResolvedValueOnce(new Response("upstream boom", { status: 500 }));
@@ -68,8 +68,6 @@ const SSH_SIGKILL_GRACE_MS = 250;
const MAX_VM_RECORD_DEPTH = 4; const MAX_VM_RECORD_DEPTH = 4;
const EXE_DEV_SSH_ONBOARDING_MARKER = "Please complete registration by running: ssh exe.dev"; const EXE_DEV_SSH_ONBOARDING_MARKER = "Please complete registration by running: ssh exe.dev";
const EXE_DEV_SSH_EMAIL_PROMPT = "Please enter your email address:"; const EXE_DEV_SSH_EMAIL_PROMPT = "Please enter your email address:";
const EXE_DEV_SSH_INVALID_KEY_FORMAT = /Load key [^\n]*invalid format/i;
const UUID_SECRET_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// exe.dev's `--setup-script` runs at VM init as the unprivileged `exedev` user, which // exe.dev's `--setup-script` runs at VM init as the unprivileged `exedev` user, which
// has passwordless sudo. The Paperclip sandbox callback bridge is a Node script, so // has passwordless sudo. The Paperclip sandbox callback bridge is a Node script, so
@@ -141,74 +139,6 @@ function isValidUrl(value: string): boolean {
} }
} }
function isSecretRef(value: string): boolean {
return UUID_SECRET_REF_RE.test(value);
}
// Catch the SSH-key paste failure modes we've seen in the wild (wrong file,
// PPK export, truncated paste) before the user pays the cost of provisioning a
// VM and getting a cryptic SSH error. Inline parse — no `ssh-keygen` dependency
// — so this also works on hosts where openssh-client isn't installed.
export function validateSshPrivateKey(rawKey: string): string | null {
const trimmed = rawKey.trim();
if (!trimmed) return null;
if (/^PuTTY-User-Key-File-\d/m.test(trimmed)) {
return "sshPrivateKey looks like a PuTTY .ppk file. Convert it to OpenSSH format (PuTTYgen → Conversions → Export OpenSSH key) and paste the resulting PEM.";
}
if (
/^(?:ssh-(?:rsa|dss|ed25519)|ecdsa-sha2-[a-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-[a-z0-9-]+)@openssh\.com)\s+\S/.test(
trimmed,
)
) {
return "sshPrivateKey looks like a PUBLIC key. Paste the matching private key (the file without the .pub extension).";
}
const headerMatch = trimmed.match(/^-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/m);
if (!headerMatch) {
return "sshPrivateKey must be a PEM-encoded private key starting with a line like '-----BEGIN OPENSSH PRIVATE KEY-----'.";
}
const footerMatch = trimmed.match(/^-----END ([A-Z0-9 ]*)PRIVATE KEY-----\s*$/m);
if (!footerMatch) {
return "sshPrivateKey is missing its '-----END … PRIVATE KEY-----' footer. Make sure you copied the whole file, including the final line.";
}
const headerLabel = headerMatch[1].trim();
const footerLabel = footerMatch[1].trim();
if (headerLabel !== footerLabel) {
return `sshPrivateKey header/footer mismatch (BEGIN ${headerLabel || "(none)"} vs END ${footerLabel || "(none)"}). The file is likely truncated or two keys are concatenated.`;
}
const headerLineEnd = trimmed.indexOf("\n", headerMatch.index ?? 0);
const footerStart = trimmed.lastIndexOf(footerMatch[0]);
if (headerLineEnd < 0 || footerStart <= headerLineEnd) {
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
}
const bodyLines = trimmed
.slice(headerLineEnd + 1, footerStart)
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (bodyLines.length === 0) {
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
}
// PEM bodies are base64 lines, optionally preceded by `Header: value` lines
// on encrypted PKCS#1 keys (`Proc-Type:`, `DEK-Info:`).
const base64Line = /^[A-Za-z0-9+/=]+$/;
const pemHeaderLine = /^[A-Za-z][A-Za-z0-9-]*:\s.+$/;
for (const line of bodyLines) {
if (!base64Line.test(line) && !pemHeaderLine.test(line)) {
return "sshPrivateKey body contains non-base64 characters. The key may have been corrupted by line-wrapping or copy-paste.";
}
}
return null;
}
function normalizeApiUrl(value: string | null): string { function normalizeApiUrl(value: string | null): string {
if (!value) return DEFAULT_API_URL; if (!value) return DEFAULT_API_URL;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -568,13 +498,6 @@ function formatSshFailure(
].join(" "); ].join(" ");
} }
if (EXE_DEV_SSH_INVALID_KEY_FORMAT.test(combinedOutput)) {
return [
`Failed to ${action} exe.dev VM ${vmName}: the configured SSH private key isn't an OpenSSH-format private key.`,
"Confirm the secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub` file or a PuTTY `.ppk` export.",
].join(" ");
}
return `Failed to ${action} exe.dev VM ${vmName}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`; return `Failed to ${action} exe.dev VM ${vmName}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`;
} }
@@ -763,10 +686,6 @@ const plugin = definePlugin({
) { ) {
errors.push("strictHostKeyChecking cannot be empty."); errors.push("strictHostKeyChecking cannot be empty.");
} }
if (config.sshPrivateKey && !isSecretRef(config.sshPrivateKey)) {
const sshKeyError = validateSshPrivateKey(config.sshPrivateKey);
if (sshKeyError) errors.push(sshKeyError);
}
warnings.push( warnings.push(
"The Paperclip host must have SSH access to the created exe.dev VM, and its SSH key must be registered with exe.dev. The API token only covers provisioning.", "The Paperclip host must have SSH access to the created exe.dev VM, and its SSH key must be registered with exe.dev. The API token only covers provisioning.",
@@ -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);
});
});

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