Compare commits

...

38 Commits

Author SHA1 Message Date
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 6cb333b986 fix(ui): order Secrets sidebar item right after Environments 2026-05-03 08:00:14 -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 7e2517935c feat(env): collapse env value textarea to single line when unfocused 2026-05-02 07:36:11 -04:00
Chris Farhood 441bbd5b9a feat(env): allow multi-line env var values via auto-growing textarea
Replace the plain-value <input> with the shadcn Textarea (which has
field-sizing: content baked in). Starts at rows={1}, grows as content
needs more vertical space. Storage and runtime already preserve newlines
so this is purely a UI capability change.
2026-05-02 07:15:08 -04:00
Chris Farhood bf1abb1492 chore(plugin-rpc): raise MAX_RPC_TIMEOUT_MS cap to 60 minutes 2026-05-01 21:00:12 -04:00
Chris Farhood 1678160c49 fix(ci): add acpx-local + sandbox plugins to fork Dockerfile deps stage 2026-05-01 19:31:44 -04:00
Chris Farhood c08c72e917 chore(ci): restore fork CI overlay 2026-05-01 19:27:04 -04:00
Chris Farhood fe43fbe2fd Merge branches 'feat/skills-gitops-complete', 'feat/company-portability-complete', 'feat/board-approval-markdown' and 'fix/remove-paperclip-dev-skill' into local 2026-05-01 19:26:56 -04:00
Dotta 685ee84e4a [codex] Document terminal bench dispatch config (#4961)
## Thinking Path

> - Paperclip agents rely on skills for repeatable operating procedures
> - The Terminal-Bench loop skill needs to preserve enough dispatch
configuration to reproduce real heartbeat behavior
> - A bare benchmark command can create unassigned work with no
heartbeat-enabled agent, which is a harness setup failure rather than
product evidence
> - The Paperclip heartbeat skill also needs to keep escalation biased
toward agent-owned follow-through
> - This pull request documents dispatch runner config requirements and
strengthens the agent follow-through rule
> - The benefit is fewer misleading benchmark loops and clearer agent
operating guidance

## What Changed

- Documented `PAPERCLIP_HARBOR_RUNNER_CONFIG` / runner dispatch config
as required Terminal-Bench loop input.
- Updated the Terminal-Bench loop smoke check to require the dispatch
config mention.
- Added stronger Paperclip skill guidance to avoid asking humans for
work an agent can perform.

## Verification

- `pnpm smoke:terminal-bench-loop-skill`

## Risks

- Low risk: documentation and smoke expectation changes only. The
stricter smoke assertion is intentional so future edits do not drop the
dispatch config requirement.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 12:00:47 -05:00
Dotta d7719423e9 [codex] Harden non-system database backup schemas (#4960)
## Thinking Path

> - Paperclip is a control plane whose database is the durable audit and
work record
> - Database backup needs to include operator/plugin schemas while
excluding PostgreSQL-owned internals
> - PostgreSQL reserves the `pg_` schema prefix for system schemas,
including temp and toast variants
> - A single escaped `pg_` prefix predicate is less brittle than
enumerating individual `pg_toast` and `pg_temp` forms
> - This pull request tightens non-system schema discovery for logical
backups without changing the normal user/plugin schema path

## What Changed

- Replaced narrow `pg_toast` and `pg_temp` schema exclusions with an
escaped `pg_` reserved-prefix exclusion.
- Kept `information_schema` excluded from logical backup metadata
discovery.
- Addressed Greptile feedback by removing redundant no-op additions from
the prior iteration.

## Verification

- `pnpm exec vitest run packages/db/src/backup-lib.test.ts`
- PR checks on the latest pushed head: policy, verify, e2e, Greptile
Review, and Snyk

## Risks

- Low risk: PostgreSQL reserves `pg_` schema names for system use, so
this should only exclude database-owned internals that should not be
restored from Paperclip logical backups.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected - check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 11:59:53 -05:00
Dotta fe401b7fa9 [codex] Polish inbox nested issue UI (#4959)
## Thinking Path

> - Paperclip orchestrates AI agents through issue lists and
issue-thread interactions
> - The inbox must preserve nested issue visibility and keyboard
navigation as work decomposes into deeper sub-issues
> - Some UI polish issues made nested rows harder to scan and pending
question cancellation less covered
> - The issue list also had a small test indentation regression around
load-more behavior
> - This pull request tightens nested inbox rendering and related
issue-thread/list polish
> - The benefit is a more reliable operator inbox for multi-level work
trees

## What Changed

- Included nested grandchild issues in inbox keyboard navigation and
recursive row rendering.
- Sort parent rows by descendant activity so active subtrees remain
visible.
- Removed extra inbox card background styling in favor of the page
surface.
- Added regression coverage for pending question cancellation.
- Cleaned up the issue-list load-more test indentation.

## Verification

- `pnpm exec vitest run ui/src/lib/inbox.test.ts
ui/src/components/IssueChatThread.test.tsx
ui/src/components/IssuesList.test.tsx`
- Screenshots were not captured in this PR split; the visible flow is
covered by focused component/helper tests and should get browser QA in
the follow-up issue.

## Risks

- Medium risk: nested inbox rendering and keyboard navigation are
user-visible. The changes are localized to inbox grouping/rendering
helpers and covered by targeted tests.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 11:58:53 -05:00
Dotta 2d72292ad6 [codex] Add workspace routine run tab (#4958)
## Thinking Path

> - Paperclip orchestrates AI agents through reusable execution
workspaces and routines
> - Operators need a fast way to run workspace-aware routines against a
specific execution workspace
> - The existing workspace detail surface showed configuration, runtime
logs, and linked issues, but not routines that depend on workspace
variables
> - Routine runs also needed to prefill the selected execution workspace
so branch variables resolve correctly
> - This pull request adds a workspace routines tab and prefilled
routine-run dialog support
> - The benefit is a tighter workflow for rerunning reviews, smoke
checks, and other workspace-specific routines

## What Changed

- Added an execution workspace `Routines` tab and company-prefixed
routes.
- Listed routines that declare or reference workspace-specific
variables.
- Added `Run now` support that preselects the current execution
workspace in `RoutineRunVariablesDialog`.
- Centralized reusable execution workspace ordering/deduplication for
issue creation and workspace cards.
- Added focused UI helper and dialog regression tests.

## Verification

- `pnpm exec vitest run ui/src/lib/reusable-execution-workspaces.test.ts
ui/src/lib/workspace-routines.test.ts
ui/src/components/RoutineRunVariablesDialog.test.tsx
ui/src/lib/company-routes.test.ts`
- Screenshots were not captured in this PR split; the visible flow is
covered by focused component/helper tests and should get browser QA in
the follow-up issue.

## Risks

- Medium risk: this adds a new workspace detail tab and routine-run
path. It is isolated to workspace-scoped routines and uses existing
routine run APIs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 11:58:15 -05:00
Dotta 570a4206da [codex] Recover productive terminal continuations (#4956)
## Thinking Path

> - Paperclip orchestrates AI agents through issue-scoped heartbeat runs
> - Recovery logic decides whether in-progress work still has a live
path after a terminal run
> - A productive terminal continuation can still leave an issue stranded
when no active run or wake remains
> - Treating that state as healthy leaves work stuck despite evidence
that more action is needed
> - This pull request re-enqueues recovery for productive terminal
continuations that left no live path
> - The benefit is fewer silently stranded in-progress issues after
agents make partial progress

## What Changed

- Reclassified successful-but-productive terminal continuations as
recoverable when no live path remains.
- Enqueue a follow-up recovery wake with the original run id and
continuation metadata.
- Added regression tests covering productive terminal continuation
recovery and advanced liveness handoff.

## Verification

- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/run-continuations.test.ts`

## Risks

- Medium risk: recovery may schedule one more follow-up where Paperclip
previously considered the work observed. The existing uniqueness,
budget, and escalation checks still constrain retry loops.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 11:57:23 -05: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
Dotta 3cd26a78fc [codex] Surface live run comment context (#4957)
## Thinking Path

> - Paperclip orchestrates AI agents through issue comments and
heartbeat runs
> - The board UI needs to distinguish a comment that triggered a live
run from comments queued after that run started
> - The run payload already stores comment context, but active-run API
responses did not expose the ids the UI needs
> - Without those ids, the triggering comment can flash as queued while
the agent is already responding to it
> - This pull request exposes live-run comment context and teaches the
optimistic comment helper to ignore the trigger comment
> - The benefit is clearer issue-chat state during comment-triggered
agent interruptions

## What Changed

- Added `contextCommentId` and `contextWakeCommentId` to active/live run
payloads.
- Threaded those ids through server routes, heartbeat summaries, UI API
types, and issue detail rendering.
- Updated optimistic comment classification to avoid marking the
triggering comment as queued.
- Added server and UI regression coverage.

## Verification

- `pnpm exec vitest run
server/src/__tests__/agent-live-run-routes.test.ts
ui/src/lib/optimistic-issue-comments.test.ts`

## Risks

- Low-to-medium risk: adds optional fields to existing run payloads.
Existing consumers should ignore unknown fields, and UI handling is
null-safe.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 10:44:11 -05:00
Dotta e8275318ba [codex] Raise agent heartbeat concurrency default (#4954)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agent heartbeat settings control how much parallel work one employee
can run
> - The previous default of 5 concurrent runs was too restrictive for
active local agent teams
> - The shared default, heartbeat clamp, docs, and route/import/UI
expectations need to agree
> - This pull request raises the default heartbeat concurrency to 20
while keeping explicit headroom up to 50 for power users
> - The benefit is higher throughput for agent teams without each new
agent needing manual runtime config edits

## What Changed

- Raised `AGENT_DEFAULT_MAX_CONCURRENT_RUNS` from 5 to 20.
- Raised the heartbeat service max clamp from 10 to 50, keeping the new
default below the ceiling.
- Updated V1 implementation docs and tests that assert default
imported/exported runtime config.
- Updated the new-agent UI runtime config test to assert the shared
default constant instead of duplicating the numeric value.

## Verification

- `pnpm exec vitest run
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/company-portability.test.ts
ui/src/lib/new-agent-runtime-config.test.ts`

## Risks

- Medium risk: new agents can consume more local execution capacity by
default. The heartbeat scheduler still respects configured max
concurrency and budget/pause controls, and operators can lower or raise
the per-agent cap within the `1..50` clamp.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use and local command
execution. Exact context window was not exposed in the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 10:42:56 -05:00
Dotta e273d621fc [PAP-3154] Stop padding /live-runs by default (#4963)
## Summary
- Fix [PAP-3154](/PAP/issues/PAP-3154): the Sidebar's "Dashboard NN
live" badge showed a constant 50 in every company because `GET
/api/companies/:companyId/live-runs` was padding its response with up to
50 recent (non-live) heartbeat runs whenever the caller did not pass
`minCount`.
- Regression introduced by
[#4875](https://github.com/paperclipai/paperclip/pull/4875) (commit
`6445bef9`), which capped both `minCount` and `limit` at 50 with a
fallback of 50 for omitted values. The cap is correct for `limit` (real
unboundedness guard); for `minCount` it conflates "no padding" with "pad
to the cap".
- Default `minCount` to 0 so callers asking for "live runs" only get
actually-live runs unless they explicitly request padding
(`ActiveAgentsPanel` is the only caller that does). Keep `limit` capped
at 50 by default.

## Test plan
- [x] `pnpm exec vitest run
server/src/__tests__/agent-live-run-routes.test.ts` — 7/7 pass,
including new tests for the no-pad default and explicit padding.
- [x] `pnpm exec vitest run ui/src/components/Sidebar.test.tsx
ui/src/components/ActiveAgentsPanel.test.tsx
ui/src/api/heartbeats.test.ts` — 6/6 pass.
- [ ] Verify in dev: with ~8 truly-live runs in a company, the sidebar
Dashboard badge shows the real count (not 50).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 10:33:13 -05:00
Dotta 42a299fb9d [codex] Bound productivity review recovery loops (#4948)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The heartbeat/productivity review subsystem detects when assigned
work is likely stuck or churning.
> - Productivity reviews are useful, but repeated reconciliation can
create noisy refresh comments or repeated review issues around the same
source issue.
> - That makes manager follow-up harder because the signal can get
buried under duplicate review activity.
> - This pull request bounds productivity review refreshes and creation
loops while preserving the existing escalation path.
> - The benefit is a quieter recovery loop that still surfaces stuck or
high-churn work for manager attention.

## What Changed

- Added refresh throttling for open productivity review issues,
including a one-hour default interval and a maximum of three refresh
comments per open review.
- Added a rolling 24-hour creation cap so completed/closed reviews
cannot immediately recreate review issues indefinitely for the same
source issue.
- Excluded cancelled productivity reviews from the creation cap so
manager cancellations do not silently suppress future legitimate
reviews.
- Preserved productivity review timestamps in deterministic test paths
and added targeted coverage for immediate refresh suppression, refresh
caps, creation caps, and cancelled-review exclusion.

## Verification

- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/productivity-review-service.test.ts`
- `pnpm exec vitest run
server/src/__tests__/productivity-review-service.test.ts`
- Greptile Review: 5/5 on commit
`bcf25832d0ffae25890b2ee7eed112d1c2d114fe` with review threads resolved.
- GitHub PR checks passed on the latest head: `policy`, `verify`, `e2e`,
`Greptile Review`, and `security/snyk (cryppadotta)`.
- Verified the branch is rebased onto `public-gh/master` with no
conflicts.
- Verified the diff does not include `pnpm-lock.yaml`, database schema
changes, or migrations.

## Risks

- Low-to-medium risk: this changes automation cadence for productivity
reviews. A truly stuck issue may receive fewer repeated refresh
comments, but the original review issue remains open and assigned for
manager action.
- No migration risk: this is server logic and tests only.

> Checked [`ROADMAP.md`](ROADMAP.md) for overlapping planned core work;
this is a targeted recovery-loop fix and does not add a new roadmap
feature.

## Model Used

- OpenAI Codex coding agent, GPT-5 model family, tool-using software
engineering mode. Exact context window is not exposed in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (not applicable; server-only change)
- [x] I have updated relevant documentation to reflect my changes (not
applicable; no user-facing docs or commands changed)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-01 08:32:04 -05: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
Devin Foley d2dd759caa plugins: make e2b template default explicit (#4901)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Remote execution environments are part of that control plane,
including sandbox-provider plugins like E2B
> - The E2B provider already normalizes config and runtime behavior
around a `base` template default
> - But the manifest still presented `template` as required, which
forces redundant operator input and makes the UI contract stricter than
runtime behavior
> - That mismatch showed up while building a repeatable QA workflow for
sandbox testing
> - This pull request makes the manifest and validation contract line up
with the existing `base` default
> - The benefit is a simpler and more accurate E2B environment setup
experience

## What Changed

- Removed the E2B manifest's `required: ["template"]` requirement so the
config schema matches runtime behavior
- Clarified the manifest description to say the template defaults to
`base` when omitted
- Added a focused unit test proving that validation normalizes a missing
template to `base`

## Verification

- Ran the focused E2B plugin test for the new behavior:
- `cd packages/plugins/sandbox-providers/e2b && pnpm test --
--testNamePattern "defaults a missing template to base"`

## Risks

- Low risk. This only loosens the schema to match the plugin's existing
runtime normalization and adds a test for that path.
- The broader E2B plugin suite currently has unrelated existing failures
outside this change; this PR does not modify those paths.

## Model Used

- OpenAI Codex, GPT-5 Codex via Codex CLI agent tooling, large-context
coding workflow with terminal tool use and local test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] I will address all Greptile and reviewer comments before
requesting merge
2026-04-30 22:43:24 -07:00
Devin Foley b02e67cea5 fix(ci): diff PR workflow paths from merge base (#4903)
## Thinking Path

> - Paperclip’s PR workflow is part of the control-plane safety surface
because it decides whether a branch is allowed to merge.
> - This issue started in that workflow: the lockfile and manifest
policy checks were diffing `base.sha..head.sha`, which incorrectly
treated unrelated `master` commits as if they belonged to the PR branch.
> - The right fix there is to diff from the PR merge base
(`base...head`) so policy checks only evaluate files introduced by the
branch itself.
> - Once that workflow fix was in place, `/checkpr` exposed a second
blocker on the PR merge ref: `verify` was failing in newer `master`-side
tests that were not part of the original branch diff.
> - The actionable repeated failure came from the ACPX local adapter
test suite, where a test hard-coded the managed Codex home under
`instances/default` even though the stable Vitest runner sets a
non-default `PAPERCLIP_INSTANCE_ID`.
> - This pull request now includes both the original CI diff-scope fix
and the targeted ACPX test fix so the PR’s actual checks align with
current base-branch execution.
> - The benefit is that the original false-positive lockfile failure is
removed, and the merge-ref verify path is hardened against the
instance-id isolation used in CI.

## What Changed

- Updated `.github/workflows/pr.yml` so the lockfile policy and manifest
policy steps diff `pull_request.base.sha...pull_request.head.sha` from
the merge base instead of using a two-dot base/head diff.
- Added an inline workflow comment explaining why the three-dot diff is
required for PR-scoped file detection.
- Updated `packages/adapters/acpx-local/src/server/execute.test.ts` so
the managed Codex home assertion uses a test-specific
`PAPERCLIP_INSTANCE_ID` instead of hard-coding `default`.
- Restored `PAPERCLIP_INSTANCE_ID` after that ACPX test finishes so the
test remains isolated and does not leak process env changes.

## Verification

- Reproduced the original false positive locally by comparing PR heads
`#4901` and `#4902` with the old `base..head` logic; both incorrectly
included `pnpm-lock.yaml` from unrelated `master` commits.
- Verified the new `base...head` logic reduces those PRs to only their
actual changed files and excludes `pnpm-lock.yaml`.
- Verified a real manifest-changing PR (`#4893`) still reports
`package.json` changes under the new logic.
- Ran `pnpm -r typecheck` successfully.
- Ran `pnpm vitest run
packages/adapters/acpx-local/src/server/execute.test.ts` successfully
after the ACPX test fix.
- Ran `pnpm vitest run packages/db/src/backup-lib.test.ts` successfully
against the merge-ref-related DB failure path observed during
`/checkpr`.
- Pushed commit `9520a976` and allowed PR `#4903` checks to rerun on the
updated branch.

## Risks

- Low risk: the workflow change only affects how PR policy checks
determine the changed file set.
- Low risk: the ACPX change is test-only and aligns the test with the
instance-isolation behavior already used by
`scripts/run-vitest-stable.mjs` in CI.
- The remaining operational risk is limited to other unrelated
merge-ref-only failures that were not reproduced in the targeted local
verification above.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, `gpt-5-codex`, via the Codex local adapter in Paperclip.
- Tool-using coding model with shell execution, git, GitHub CLI, and
repository inspection in a local worktree.
- Context included the current repo, the Paperclip task thread, PR check
output, and the isolated execution workspace.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-30 21:22:40 -07:00
github-actions[bot] 6a7cca95ef chore(lockfile): refresh pnpm-lock.yaml (#4899)
Auto-generated lockfile refresh after dependencies changed on master.
This PR only updates pnpm-lock.yaml.

Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-04-30 20:00:07 -05:00
Dotta 4272c1604d Add ACPX local adapter runtime (#4893)
## Thinking Path

> - Paperclip orchestrates AI-agent companies through a control plane
that can start, supervise, and recover agent runs.
> - Local adapters are the bridge between Paperclip issues and concrete
agent runtimes such as Claude, Codex, and other ACP-compatible tools.
> - The roadmap calls out broader “bring your own agent” and claw-style
agent support, and ACPX gives Paperclip one path to normalize multiple
ACP agents behind a single adapter.
> - The branch needed to become one reviewable PR against current
`paperclipai/paperclip:master`, without carrying stale base conflicts or
generated lockfile churn.
> - This pull request adds an experimental built-in `acpx_local`
adapter, integrates it through the server/CLI/UI adapter surfaces, and
adds regression coverage for runtime execution, skill sync, stream
parsing, diagnostics, and log redaction.
> - The benefit is that Paperclip can run Claude/Codex/custom ACP agents
through ACPX while keeping operator configuration, skills, logging, and
transcript rendering inside the existing adapter model.

## What Changed

- Added `@paperclipai/adapter-acpx-local` with server execution, config
schema, ACPX session handling, CLI formatting, UI config helpers, and
stdout parsing.
- Registered `acpx_local` across CLI, server, shared constants, UI
adapter metadata, adapter capabilities, and agent creation/editing
surfaces.
- Added ACPX runtime execution support with persistent sessions,
local-agent JWT environment handling, skill snapshots, runtime skill
materialization, and isolation/security regressions.
- Added ACPX adapter diagnostics and marked the adapter experimental in
the UI.
- Added command/env secret redaction for resolved command metadata in
adapter-utils, server event storage, and the Agent Detail invocation UI.
- Added Storybook coverage for ACPX config, transcript rendering, and
skill states, plus PR screenshots under `docs/pr-screenshots/pap-2944/`.
- Rebased the branch onto current `public-gh/master`; `pnpm-lock.yaml`
is intentionally not included and there are no migration/schema changes.

## Verification

- `pnpm exec vitest run
packages/adapters/acpx-local/src/server/execute.test.ts
packages/adapters/acpx-local/src/server/test.test.ts
packages/adapters/acpx-local/src/cli/format-event.test.ts
packages/adapters/acpx-local/src/ui/parse-stdout.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/redaction.test.ts
server/src/__tests__/acpx-local-execute.test.ts
server/src/__tests__/acpx-local-skill-sync.test.ts
server/src/__tests__/acpx-local-adapter-environment.test.ts
server/src/__tests__/adapter-routes.test.ts
server/src/__tests__/agent-skills-routes.test.ts
ui/src/adapters/metadata.test.ts` — 12 files, 87 tests passed.
- `pnpm --filter @paperclipai/adapter-acpx-local typecheck` — passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- Confirmed PR diff does not include `pnpm-lock.yaml`, database schema
files, or migrations.

Screenshots:

![ACPX Claude skills
light](https://github.com/cryppadotta/paperclip-1/blob/PAP-2944-acpx-make-a-claude_local-adapter-that-uses-acpx-instead/docs/pr-screenshots/pap-2944/skills-claude-light.png?raw=true)
![ACPX Claude skills
dark](https://github.com/cryppadotta/paperclip-1/blob/PAP-2944-acpx-make-a-claude_local-adapter-that-uses-acpx-instead/docs/pr-screenshots/pap-2944/skills-claude-dark.png?raw=true)
![ACPX custom skills
light](https://github.com/cryppadotta/paperclip-1/blob/PAP-2944-acpx-make-a-claude_local-adapter-that-uses-acpx-instead/docs/pr-screenshots/pap-2944/skills-custom-light.png?raw=true)

## Risks

- Medium risk: this introduces a new built-in adapter package and
touches runtime execution, adapter registration, agent config, skills,
and transcript rendering.
- ACPX and ACP agent behavior can vary by installed tool versions; the
adapter is marked experimental to set operator expectations.
- `pnpm-lock.yaml` is excluded per repository PR policy, so dependency
lock refresh must be handled by the repo’s automation or maintainers.
- No database migration risk: no schema or migration files changed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with repository tool use,
shell execution, git operations, and local verification. Exact hosted
context window was not exposed in this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 19:57:05 -05:00
Dotta ad5432fece [codex] Harden issue recovery reliability (#4875)
## Thinking Path

> - Paperclip is the control plane for autonomous agent companies, so
non-terminal issue state must always have a clear live, waiting, or
recovery owner.
> - This change stays inside the server reliability and liveness
subsystem for assigned issue recovery, blocker attention, and live-run
polling.
> - Closed PR #4860 mixed this reliability work with separate
mutation-boundary policy changes, which made review and merge risk too
broad.
> - [PAP-2981](/PAP/issues/PAP-2981) asked for a replacement PR
containing only the remaining reliability slice and explicitly excluding
user-assignment and execution-policy restrictions.
> - Follow-up review also split `advanced` run-liveness continuation
behavior out of this PR so it can be reviewed separately.
> - The implementation hardens repeated recovery escalation, expands
blocker-attention coverage for explicit waiting and recovery paths, and
caps company live-run polling defaults.
> - The benefit is a smaller reliability PR that improves liveness
behavior without changing agent/user mutation authorization boundaries
or `advanced` continuation semantics.

## What Changed

- Avoid repeated liveness escalation updates when the source issue is
already blocked by the same open escalation.
- Treat open liveness escalation recovery issues, their source issues,
and their leaf blockers as covered waiting paths in blocker attention.
- Cap default company live-run polling at 50 rows for both `minCount`
and `limit`, including explicit zero values, to avoid unbounded
responses.
- Preserve the existing behavior where succeeded `advanced` runs are
considered productive/healthy for stranded-work recovery and are not
actionable bounded run-liveness continuations.
- Added focused server coverage for recovery dedupe, blocker attention,
liveness escalation, run continuations, and live-run polling.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts
server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts
server/src/__tests__/issue-blocker-attention.test.ts
server/src/__tests__/run-continuations.test.ts
server/src/__tests__/agent-live-run-routes.test.ts`
- Result: 5 files passed, 63 tests passed.
- `pnpm --filter @paperclipai/server typecheck`
- Result: passed.
- No UI changes; screenshots are not applicable.

## Risks

- Recovery and blocker-attention classification changes can affect which
blocked chains are shown as covered versus needing attention.
- Live-run polling now treats omitted, invalid, or non-positive `limit`
/ `minCount` values as the capped default of 50.
- `advanced` run-liveness continuation behavior is intentionally
excluded from this PR and split for separate review.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5, code execution and GitHub CLI tool use, medium
reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 16:44:28 -05:00
Dotta a3de1d764d Add cheap model profiles for local adapters (#4881)
## Thinking Path

> - Paperclip is a control plane for autonomous AI companies, where
adapters are the boundary between the board, agents, and execution
runtimes.
> - Local adapters currently expose a primary runtime configuration, but
operators often need a cheaper model lane for routine or low-risk work.
> - That cheap lane has to stay adapter-owned: runtime profile settings
should not mutate the primary adapter config or bypass existing
auth/secret mediation.
> - Issue creation also needs an ergonomic way to request primary,
cheap, or custom model behavior for a selected assignee.
> - This pull request adds a first-class `cheap` model profile contract
across adapter capabilities, heartbeat config resolution, agent
configuration, and issue creation.
> - The benefit is cheaper task execution can be configured and
requested explicitly while preserving adapter boundaries, secret
handling, and audit visibility.

## What Changed

- Added adapter model-profile capability metadata and a `cheap` profile
contract for supported local adapters.
- Applied `runtimeConfig.modelProfiles.cheap.adapterConfig` during
heartbeat config resolution, including requested/applied/fallback run
metadata.
- Added agent configuration UI for cheap model profile settings without
writing those settings into primary `adapterConfig`.
- Added New Issue assignee model lane controls for Primary / Cheap /
Custom and request payload handling.
- Added run ledger profile badges and Storybook stories for the new
cheap-lane UI states.
- Added tests for validators, heartbeat model profile application,
permission/secret mediation, UI payload helpers, and run ledger
rendering.
- Added committed UI verification screenshots under
`docs/pr-screenshots/pap-2837/`.
- Addressed Greptile review feedback around cheap-profile defaults,
shared profile types, and fallback test data.

## Verification

Local:

- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
server/src/__tests__/adapter-registry.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/lib/agent-config-patch.test.ts
ui/src/lib/issue-assignee-overrides.test.ts
ui/src/lib/new-agent-runtime-config.test.ts` — passed, 8 files / 103
tests.
- `pnpm exec vitest run ui/src/lib/new-agent-runtime-config.test.ts
ui/src/components/IssueRunLedger.test.tsx` — passed after
Greptile/rebase follow-up, 2 files / 17 tests.
- `pnpm --filter @paperclipai/ui typecheck` — passed after
Greptile/rebase follow-up.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — did not complete successfully in this local
worktree: it stopped in pre-existing `@paperclipai/adapter-utils`
sandbox/SSH fixture suites outside this PR diff. Failures were 5s local
timeouts plus `git init -b` unsupported by this machine's Git 2.21.0.
The branch-specific targeted suites above passed.
- Branch was fetched/rebased onto `public-gh/master`; `git rev-list
--left-right --count public-gh/master...HEAD` reports `0 9`.

Remote PR checks on latest head
`e30bf399146451c86cee98ed528d51d33fa5af5a`:

- `policy` — passed.
- `verify` — passed.
- `e2e` — passed.
- `Greptile Review` — passed, confidence score 5/5; Greptile review
threads resolved.
- `security/snyk (cryppadotta)` — passed.

Screenshots:

- [New issue cheap lane
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png)
- [New issue custom lane
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png)
- [New issue unsupported adapter
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png)
- [Run ledger model profile badges
desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png)
- Mobile variants are also in `docs/pr-screenshots/pap-2837/`.

## Risks

- Medium: heartbeat config mediation now merges runtime model profiles
into adapter configs, so adapter secret normalization and host-command
restrictions must keep covering nested config paths.
- Medium: the UI adds another issue creation choice; unsupported
adapters must keep hiding the cheap lane and preserve primary behavior.
- Low migration risk: no database migration is included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex coding agent using GPT-5-class reasoning with repo tool use
and command execution. Exact served model/context window was not exposed
by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:32:04 -05:00
Dotta 1fe1067361 Polish board settings and skills workflow (#4863)
## Thinking Path

> - Paperclip's board UI and bundled skills are the operator layer for
configuring agents, routines, issue workflows, and local troubleshooting
loops.
> - The prior rollup mixed this operator polish with database backups,
backend reliability, thread scale, and cost/workflow primitives.
> - This pull request isolates the remaining board QoL, settings,
issue-detail integration, adapter config cleanup, and skills smoke
tooling.
> - It includes some integration-level overlap with the thread and
workflow slices so this branch can run from `origin/master` while still
preserving the full original work.
> - Preferred merge order is the narrower primitives first, then this
integration PR last.
> - The benefit is that reviewers can inspect the user-facing
board/settings/skills layer separately from backend infrastructure
changes.

## What Changed

- Added board/settings polish for agents, routines, company settings,
project workspace detail, and issue detail controls.
- Added agent/routine UI regression tests and New Issue dialog coverage.
- Integrated issue-detail activity/cost/interaction surfaces and leaf
work pause/resume controls.
- Cleaned bundled adapter UI config defaults and onboarding copy.
- Added terminal-bench loop and work-stoppage diagnosis skills plus a
smoke test script.
- Updated attachment type handling and Paperclip skill/API guidance.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/pages/Agents.test.tsx
ui/src/pages/Routines.test.tsx ui/src/components/NewIssueDialog.test.tsx
ui/src/pages/IssueDetail.test.tsx
server/src/__tests__/costs-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts`
- Result: 7 test files passed, 54 tests passed.
- `pnpm run smoke:terminal-bench-loop-skill`
- Result: JSON output included `"ok": true` and `"cleanup": true`.
- UI screenshots not included because verification is focused
component/page coverage for the changed board surfaces.

## Risks

- This is the integration-heavy PR in the split and intentionally
overlaps some component/API primitives with the issue-thread and
workflow PRs so it can run from `origin/master`.
- Preferred merge order: #4859, #4860, #4861, #4862, then this PR last.
If earlier branches merge first, this PR may need a straightforward
conflict refresh in shared UI files.
- The terminal-bench smoke script creates temporary mock issues and
relies on cleanup; the verified run returned `cleanup: true`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 15:28:11 -05:00
Dotta c4269bab59 Add workflow interaction cancellation and issue cost summaries (#4862)
## Thinking Path

> - Paperclip coordinates work through issue-thread interactions, run
history, and cost telemetry.
> - Operators need workflow prompts to be cancellable and costs to be
visible at the issue level.
> - The earlier rollup mixed this workflow/cost work with database
backups, reliability recovery, thread scaling, and settings polish.
> - This pull request isolates the interaction and cost surfaces into a
reviewable slice.
> - The backend now supports cancelling pending question interactions
and summarizing issue-tree costs.
> - The UI component layer can render cancelled questions and interleave
activity with run ledger rows.

## What Changed

- Added `cancelled` as an issue-thread interaction status and result
shape for question interactions.
- Added the board-only `POST
/issues/:id/interactions/:interactionId/cancel` route and service
implementation.
- Added issue-tree cost summary support in the cost service and
`/issues/:id/cost-summary` API route.
- Extended shared cost exports and UI API/query keys for issue cost
summaries.
- Updated `IssueThreadInteractionCard` and `IssueRunLedger` components
for cancelled questions, issue cost surfaces, and activity/run
interleaving.
- Added focused server and component regression coverage.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run server/src/__tests__/costs-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- Result: 4 test files passed, 45 tests passed.
- UI screenshots not included because this PR updates reusable
components and API surfaces without wiring a new page-level layout.

## Risks

- Adds a new interaction terminal status; clients that switch
exhaustively on interaction status may need to handle `cancelled`.
- Issue-tree cost summaries use recursive issue traversal and should be
watched on unusually large issue trees.
- Page-level issue detail wiring is intentionally left to the board
QoL/issue-detail branch to keep this PR narrow.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 13:57:25 -05:00
Dotta 87f19cd9a6 Improve issue thread scale and markdown polish (#4861)
## Thinking Path

> - Paperclip's board UI is the operator surface for supervising
AI-agent companies.
> - Issue threads are where operators read progress, respond to agents,
inspect markdown, and jump through long histories.
> - Large threads and rich markdown had become difficult to navigate and
expensive to render.
> - The previous rollup mixed these UI scale fixes with unrelated
backend recovery, costs, backups, and settings changes.
> - This pull request isolates the issue-thread scale and markdown
polish work.
> - The benefit is a reviewable UI slice that can merge independently of
the backend reliability, database backup, workflow, and board QoL PRs.

## What Changed

- Virtualized long issue chat threads and stabilized
anchor/jump-to-latest behavior for large histories.
- Added incremental issue-list row loading and tests for
scroll-triggered pagination behavior.
- Hardened markdown body rendering and markdown editor behavior around
HTML tags, image drops, code-copy UI, and escaped newline handling.
- Added a long-thread measurement harness at
`scripts/measure-issue-chat-long-thread.mjs` plus
`perf:issue-chat-long-thread`.
- Added focused UI/lib regression coverage for thread rendering,
markdown, optimistic comments, and message building.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx
ui/src/components/IssuesList.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/lib/issue-chat-messages.test.ts
ui/src/lib/optimistic-issue-comments.test.ts`
- Result: 6 test files passed, 170 tests passed.
- UI screenshots not included because this PR is covered by targeted
component tests and does not introduce a new page layout.

## Risks

- Virtualization changes can affect scroll anchoring in edge cases on
very long threads.
- Markdown/editor hardening changes are intentionally defensive, but
malformed content may render differently than before.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 13:18:01 -05:00
Dotta cd606563f6 Expand database backups to non-system schemas (#4859)
## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - Reliable backups are part of operating that control plane safely.
> - The previous backup path was public-schema oriented and did not
clearly cover plugin-owned schemas or migration history.
> - Paperclip now has plugin database namespaces and Drizzle migration
state that must survive backup/restore.
> - This pull request expands logical database backups to non-system
schemas and documents the backup boundary.
> - The benefit is safer restore behavior for core and plugin-owned
database state without implying full filesystem disaster recovery.

## What Changed

- Include non-system database schemas in JavaScript and pg_dump backup
paths.
- Preserve enum, table, sequence, index, constraint, migration, and
plugin-schema objects across backup/restore.
- Add restore coverage for plugin-owned schemas and Drizzle migration
history.
- Clarify docs that DB backups are logical database backups, not full
instance filesystem backups.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run packages/db/src/backup-lib.test.ts`
- Result: 1 test file passed, 4 tests passed.
- Confirmed this PR does not include `pnpm-lock.yaml` or
`.github/workflows/*` changes.

## Risks

- Medium: backup generation touches schema discovery and restore
ordering, so unusual database objects may need additional coverage
later.
- No migrations are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use enabled, medium reasoning
effort. Exact hosted context-window details are not exposed in this
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Note: no UI changes are included in this PR, so screenshots are not
applicable.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 12:54:35 -05:00
Devin Foley c0ce35d1fb Improve E2B plugin configuration UX and fix execution timeouts (#4802)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - E2B is a sandbox provider plugin that runs agent code in isolated
cloud environments
> - Operators configure E2B through the plugin settings page
> - But the E2B API key configuration was unclear — the settings field
description didn't explain that pasted keys are auto-saved as company
secrets, and the fallback to the host `E2B_API_KEY` variable wasn't
documented
> - Additionally, long-running E2B sandbox commands were timing out
because the plugin environment RPC driver used a fixed timeout, and
environment commands competed for the single foreground command slot
> - This PR clarifies the E2B configuration UX, fixes RPC timeouts for
plugin environment execution, and runs E2B environment commands in
background mode to avoid blocking the foreground slot
> - The benefit is clearer E2B setup for operators and more reliable
sandbox command execution

## What Changed

- Updated E2B plugin manifest and settings UI to clarify API key
configuration — field description now explains that pasted keys are
saved as company secrets and documents the `E2B_API_KEY` host fallback
- Added test coverage for the plugin settings page rendering
- Fixed `plugin-environment-driver.ts` to pass the configured timeout
through to RPC calls instead of using a hardcoded default
- Updated `environment-runtime.ts` to propagate timeout from the
environment lease to the plugin driver
- Changed E2B sandbox command execution to use background handles so
long-running agent commands don't block the foreground slot needed by
the callback bridge

## Verification

- `pnpm test` — all existing and new tests pass
- `pnpm typecheck` — clean
- Manual: navigate to plugin settings, verify E2B API key field shows
the updated description text
- Manual: run an E2B-backed agent task with a long-running command,
verify it completes without RPC timeout

## Risks

- Low risk. Configuration UX change is cosmetic. The timeout fix passes
an existing value through instead of dropping it. Background command
execution is a behavioral change but only affects E2B sandbox commands —
the foreground slot is still available for bridge health checks.

## Model Used

Codex GPT 5.4 high via Paperclip.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 17:12:30 -07:00
Devin Foley a4ac6ff133 Add sandbox callback bridge for remote environment API access (#4801)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents can run inside sandboxed environments like E2B, which are
isolated from the host network
> - Sandboxed agents need to call back to the Paperclip API to report
progress, post comments, and update issue status
> - But sandbox environments cannot reach the Paperclip server directly
because they run in isolated network namespaces
> - This PR adds a callback bridge that proxies API requests from the
sandbox to the Paperclip server, running as a local HTTP server on the
host that forwards authenticated requests
> - The bridge is started automatically when an adapter launches a
sandbox execution, and torn down when the run completes
> - The benefit is sandboxed agents can interact with the Paperclip API
without requiring network-level access to the host, enabling E2B and
similar providers to work end-to-end

## What Changed

- Added `sandbox-callback-bridge.ts` in `packages/adapter-utils/` — a
lightweight HTTP bridge server that accepts requests from sandbox
environments and proxies them to the Paperclip API with authentication
- Added request validation and security policy: the bridge only forwards
requests to the configured API URL, validates content types, enforces
size limits, and rejects non-API paths
- Wired the bridge into all remote adapter execute paths (claude, codex,
cursor, gemini, pi) — the bridge starts before the agent process and the
bridge URL is passed via environment variables
- Updated `environment-execution-target.ts` to prefer the explicit API
URL from environment lease metadata for sandbox callback routing
- Fixed Claude sandbox runtime setup to work with the bridge
configuration
- Added comprehensive test coverage for bridge request handling, policy
enforcement, and sandbox execution integration
- Fixed browser bundling — the bridge module is excluded from the
frontend bundle via the adapter-utils index export

## Verification

- `pnpm test` — all existing and new tests pass, including bridge unit
tests and sandbox execution integration tests
- `pnpm typecheck` — clean
- Manual: configure an E2B environment, run an agent task, verify the
agent can post comments and update issue status through the bridge

## Risks

- Medium. This is a new network-facing component (HTTP server on
localhost). The security policy restricts forwarding to the configured
API URL only and validates all requests, but any proxy introduces attack
surface. The bridge binds to localhost only and is scoped to the
lifetime of a single agent run.

## Model Used

Codex GPT 5.4 high via Paperclip.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 16:37:34 -07:00
Devin Foley 4cf612a92d Fix runtime state race, workspace sync, plugin startup, and orphaned leases (#4804)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents run inside environments that are leased, and the server
manages runtime state, workspace configuration, and plugin lifecycle
> - Several edge cases caused failures during concurrent operations: a
race condition in runtime state insertion could produce duplicate-key
errors, reused workspaces didn't sync their configuration when the
parent issue was updated, sandbox provider plugins could be queried
before registration completed, and orphaned environment leases from
failed runs were never released
> - This PR fixes these four runtime/environment issues
> - The benefit is more reliable concurrent agent execution and proper
resource cleanup

## What Changed

- `services/heartbeat.ts`: Fixed a race condition where concurrent
runtime state inserts could fail with a duplicate-key error by using an
upsert pattern
- `services/issues.ts`: Sync reused workspace configuration when an
issue is updated, so the workspace reflects the latest issue state
- `services/environment-runtime.ts`: Fixed a startup race where sandbox
provider plugins could be queried before registration completed, by
awaiting plugin readiness before resolving environment drivers
- `services/heartbeat.ts`: Release environment leases for orphaned runs
that lost their process without cleanup

## Verification

- `pnpm test` — all existing and new tests pass, including new tests for
runtime state upsert and process recovery lease cleanup
- `pnpm typecheck` — clean
- Manual: trigger concurrent agent runs to verify no duplicate-key
failures; verify orphaned leases are released after process loss

## Risks

- Low risk. The runtime state upsert changes insert-to-upsert behavior,
which could mask a legitimate duplicate if two different runs produce
the same key — but this is prevented by the run ID being part of the
key. The plugin startup await is bounded by the existing registration
timeout.

## Model Used

Codex GPT 5.4 high via Paperclip.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 16:37:10 -07:00
Devin Foley f9cf1d2f6a Add cursor sandbox support and fix SSH workspace sync (#4803)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents can run inside sandboxed environments like E2B, or on remote
hosts via SSH
> - The cursor adapter needs to resolve `cursor-agent` inside sandbox
environments where it's installed in `~/.local/bin`
> - But when using the default `agent` command on a sandbox target, the
adapter didn't know to look in `~/.local/bin/cursor-agent`, causing
"command not found" failures
> - Additionally, repeated SSH runs failed because `git checkout` during
workspace sync conflicted with leftover `.paperclip-runtime` files from
previous runs
> - This PR adds sandbox-aware command resolution for cursor and fixes
the SSH workspace sync conflict
> - The benefit is cursor works in E2B sandboxes out of the box, and
repeated SSH runs don't fail on workspace sync

## What Changed

- `cursor-local`: Added `prepareCursorSandboxCommand` — on sandbox
targets, reads the remote `$HOME`, prepends `~/.local/bin` to PATH, and
prefers `~/.local/bin/cursor-agent` when the default command is
requested; tightened the sandbox command probe to validate the binary
exists before launching; preserves explicit custom command overrides
- `adapter-utils/ssh.ts`: Added `--force` to git checkout in SSH
workspace sync to handle `.paperclip-runtime` untracked file conflicts
from previous runs

## Verification

- `pnpm test` — all existing and new tests pass, including cursor
sandbox probe, sandbox execution, and custom command override tests
- `pnpm typecheck` — clean
- Manual: configure an E2B environment, run a cursor-local task, verify
it resolves cursor-agent from the sandbox install path

## Risks

- Low-medium. The `--force` flag on git checkout could discard
uncommitted changes in the remote workspace, but the workspace is
managed by Paperclip and should not contain user edits.

## Model Used

Codex GPT 5.4 high via Paperclip.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 16:12:06 -07:00
Devin Foley a0f5cbffd7 Harden release flow with registry verification and dist-tag checks (#4800)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Paperclip is distributed as npm packages, including plugins like
`plugin-e2b`
> - The release process publishes canary and stable builds via npm
dist-tags
> - But there was no automated verification that published packages
actually landed with the correct dist-tags, and broken canary publishes
could silently ship to users
> - This PR adds a registry verification script that checks published
packages match their expected dist-tags, and wires it into PR CI so
regressions are caught before merge
> - The benefit is release integrity is verified automatically, and
broken dist-tag states are caught early

## What Changed

- Added `scripts/verify-release-registry-state.mjs` — verifies that
published npm packages have correct dist-tag assignments and detects
orphaned or mispointed tags
- Added `scripts/verify-release-registry-state.test.mjs` — test coverage
for the verification logic
- Updated `scripts/release.sh` to include canary dist-tag safety checks
before publishing
- Updated `.github/workflows/pr.yml` to run registry verification as a
CI step
- Updated `doc/PUBLISHING.md` and `doc/RELEASING.md` with the new
verification workflow

## Verification

- `pnpm test` — all tests pass including new verification script tests
- `node scripts/verify-release-registry-state.mjs` — runs against the
live npm registry and reports current state
- CI: the new PR workflow step runs on every PR push

## Risks

- Low risk. This is additive CI and tooling — no runtime code changes.
The registry verification is read-only (queries npm, does not publish).
The release script changes add safety checks that abort before
publishing if state is unexpected.

## Model Used

Codex GPT 5.4 high via Paperclip.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 15:56:20 -07:00
Devin Foley 367d4cab72 Fix SSH callback URL selection for LAN and private networks (#4799)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents can run on remote hosts via SSH environments
> - When a remote agent needs to call back to the Paperclip API, it
needs a reachable URL
> - But the runtime API URL candidate builder did not account for
private network topologies where the server is only reachable via LAN or
VPN addresses
> - Agents on SSH hosts were failing to connect because the callback URL
pointed to localhost or an unreachable address
> - This PR fixes callback URL selection to honor `PAPERCLIP_API_URL`,
prefer LAN-reachable candidates, filter unreachable link-local
addresses, and include interface hosts in onboarding invite URLs
> - The benefit is SSH-based agents can reliably reach the Paperclip API
on private networks without manual URL configuration

## What Changed

- `runtime-api.ts`: Added `PAPERCLIP_API_URL` as a first-priority
candidate in `buildRuntimeApiCandidateUrls`; extracted
`collectReachableInterfaceHosts` to enumerate non-loopback,
non-link-local network interface IPs with IPv4 preference
- `server/src/index.ts`: Export `PAPERCLIP_API_URL` from the server
environment so it is available to callback candidate resolution
- `server/src/routes/access.ts`: Include LAN interface hosts in
onboarding invite connection candidates
- `server/src/config.ts`: Attempted auto-allowing LAN interface hosts,
then reverted to the per-instance allowlist approach (both commits
included for history clarity)

## Verification

- `pnpm test` — all existing and new tests pass, including new tests for
LAN candidate ordering and link-local filtering
- `pnpm typecheck` — clean
- Manual: start a Paperclip server on a machine with a LAN IP, create an
SSH environment pointing to another host on the same LAN, verify the
agent's callback URL uses the LAN IP rather than localhost

## Risks

- Low-medium. The candidate list now includes more addresses (all
non-loopback LAN interfaces). These are candidates for the agent to try,
not an allowlist — the server's allowed hostnames still gate which
origins are accepted. Ordering change (LAN preferred over loopback)
could affect existing setups where localhost was intentionally
preferred.

## Model Used

Codex GPT 5.4 high via Paperclip.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 15:56:17 -07:00
Devin Foley 9b99d30330 Add dedicated environment settings page and test-in-environment (#4798)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents run inside environments (local, SSH, E2B sandbox)
> - Operators need to configure and manage these environments
> - But environment settings were buried inside the general company
settings page, making them hard to find
> - Additionally, when testing an agent from the configuration form, the
test always ran locally regardless of which environment was selected
> - This PR moves environments into a dedicated top-level company
settings section and wires the "Test Environment" button to run inside
the selected environment
> - The benefit is operators can find and manage environments more
easily, and the test button now validates the actual environment the
agent will use

## What Changed

- Added a dedicated `CompanyEnvironments` settings page with its own
route and sidebar entry
- Updated `CompanySettingsSidebar` and `CompanySettingsNav` to include
the new environments section
- Modified the agent test route (`POST /agents/:id/test`) to accept an
optional `environmentId` parameter
- Updated all adapter `test.ts` handlers to resolve and use the
specified execution target environment
- Added `resolveTestExecutionTarget` to `execution-target.ts` for remote
environment test resolution with cwd fallback
- Moved the "Test Environment" button and its feedback display into the
`NewAgent` page footer for better UX flow

## Verification

- `pnpm test` — all existing and new tests pass
- `pnpm typecheck` — clean
- Manual: navigate to Company Settings, confirm "Environments" appears
as a top-level section
- Manual: configure an agent with a non-local environment, click "Test
Environment", confirm the test runs inside that environment

## Risks

- Low risk. UI-only routing change for the settings page. The
test-in-environment change adds an optional parameter with a local
fallback, so existing behavior is preserved when no environment is
specified.

## Model Used

Codex GPT 5.4 high via Paperclip.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-29 15:56:13 -07:00
299 changed files with 22742 additions and 2308 deletions
+93
View File
@@ -0,0 +1,93 @@
name: "Build: Dev"
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: runners-farhoodlabs
timeout-minutes: 30
outputs:
image-tag: ${{ steps.tag.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image tag
id: tag
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/farhoodlabs/paperclip-dev
tags: |
type=raw,value=latest
type=sha,prefix=
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: .farhoodlabs/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
update-infra:
needs: build
runs-on: runners-farhoodlabs
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.PAPERCLIP_APP_ID }}
private-key: ${{ secrets.PAPERCLIP_APP_PRIVATE_KEY }}
repositories: paperclip-infra
- name: Update dev image tag in infra repo
run: |
SHA="${{ needs.build.outputs.image-tag }}"
FILE="overlays/dev/kustomization.yaml"
response=$(curl -sS \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE")
file_sha=$(echo "$response" | jq -r '.sha')
content=$(echo "$response" | jq -r '.content' | base64 -d)
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
encoded=$(printf '%s' "$new_content" | base64 -w 0)
curl -sS -X PUT \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
+53
View File
@@ -0,0 +1,53 @@
name: "Build: Production"
on:
push:
branches: [local]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: runners-farhoodlabs
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/farhoodlabs/paperclip
tags: |
type=raw,value=latest
type=sha,prefix=
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
+80
View File
@@ -0,0 +1,80 @@
# Paperclip Fork — Project Context
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
Fork repo: https://github.com/farhoodlabs/paperclip
## Branch Model
| Branch | Purpose |
|---|---|
| `master` | Mirrors `upstream/master` exactly + `.farhoodlabs/` overlay directory + `assemble-local.yml` action. Never commit application code here. |
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `ghcr.io/farhoodlabs/paperclip`. |
| `dev` | Development branch based on upstream/master. Builds `ghcr.io/farhoodlabs/paperclip-dev` on every push. |
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete`, `fix/far-108-k8s-adapter-reaper-liveness` — open PRs to upstream, never rebase onto master/local. |
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
## Fork Overlay (`.farhoodlabs/`)
Files committed to `master` that get copied into position on `local` by the assemble action:
```
.farhoodlabs/
CLAUDE.md → CLAUDE.md (repo root)
Dockerfile → Dockerfile
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
```
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
To modify fork-specific files, edit them in `.farhoodlabs/` on `master` and push — the assemble action will apply them to `local` automatically.
## Pending Upstream PRs (included in `local`)
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
| PR | Branch | Method | Notes |
|---|---|---|---|
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
| #4162 | `fix/far-108-k8s-adapter-reaper-liveness` | cherry-pick | K8s adapter reaper liveness |
## Common Tasks
### Sync upstream into master
```bash
git fetch upstream
git push origin upstream/master:master --force-with-lease
# assemble-local.yml triggers automatically and rebuilds local
```
### Add a new pending PR to local
Edit `.github/workflows/assemble-local.yml` on `master`:
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
### Remove a PR after upstream merges it
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `master`.
### Submit a new PR to upstream
Branch from `upstream/master` (not from `local` or `master`):
```bash
git fetch upstream
git checkout -b feat/my-feature upstream/master
```
### Modify the fork Dockerfile
Edit `.farhoodlabs/Dockerfile` on `master`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
## Deployment
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `ghcr.io/farhoodlabs/paperclip:latest`.
## Key Files
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `master`
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
+98
View File
@@ -0,0 +1,98 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
# Modify the existing node user/group to have the specified UID/GID to match host user
RUN usermod -u $USER_UID --non-unique node \
&& groupmod -g $USER_GID --non-unique node \
&& usermod -g $USER_GID -d /paperclip node
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY cli/package.json cli/
COPY server/package.json server/
COPY ui/package.json ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY patches/ patches/
RUN pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY --from=deps /app /app
COPY . .
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
WORKDIR /app
COPY --chown=node:node --from=build /app /app
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, editor tools
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
&& chmod +x /usr/local/bin/kubectl \
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& mv /root/.local/bin/uv /usr/local/bin/uv \
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
&& curl -fsSL https://codeberg.org/forgejo-contrib/forgejo-cli/releases/download/v0.4.1/forgejo-cli-linux.tar.gz | tar -xzf - -C /usr/local/bin \
&& chmod +x /usr/local/bin/fj \
&& curl -fsSL https://github.com/JKamsker/forgejo-cli-ex/releases/download/v0.1.7/fj-ex-linux-x86_64.tar.gz | tar -xzf - -C /usr/local/bin \
&& chmod +x /usr/local/bin/fj-ex \
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
&& chmod +x /usr/local/bin/fgj \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \
PORT=3100 \
SERVE_UI=true \
PAPERCLIP_HOME=/paperclip \
PAPERCLIP_INSTANCE_ID=default \
USER_UID=${USER_UID} \
USER_GID=${USER_GID} \
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
OPENCODE_ALLOW_ALL_MODELS=true
VOLUME ["/paperclip"]
EXPOSE 3100
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
+193
View File
@@ -0,0 +1,193 @@
name: Assemble local branch
# Triggers on every master push (i.e. after syncing upstream) and on demand.
# Builds the `local` branch: master + fork overlay + cherry-picked pending upstream PRs.
# Syncs build-dev.yml to the `dev` branch so every dev push triggers a build.
#
# PR entries support an optional "exclude:BRANCH" suffix to handle cases where
# one PR branch was rebased onto another. The exclude branch's commits are subtracted
# from the cherry-pick range so they aren't double-applied.
#
# When upstream merges a PR, remove its entry from PR_CHERRY_PICK or PR_SQUASH below.
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: write
actions: write
jobs:
assemble:
runs-on: runners-farhoodlabs
timeout-minutes: 15
steps:
- name: Checkout master
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Fetch all remotes
run: |
git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true
git fetch --all --quiet
- name: Assemble local branch
run: |
set -euo pipefail
# Start local from master (which mirrors upstream)
git checkout -B local origin/master
# Apply fork overlay: Dockerfile, build workflows, CLAUDE.md
cp .farhoodlabs/Dockerfile Dockerfile
cp .farhoodlabs/CLAUDE.md CLAUDE.md
mkdir -p .github/workflows
cp .farhoodlabs/.github/workflows/build-prod.yml .github/workflows/build-prod.yml
cp .farhoodlabs/.github/workflows/build-dev.yml .github/workflows/build-dev.yml
git add Dockerfile CLAUDE.md .github/workflows/build-prod.yml .github/workflows/build-dev.yml
git commit -m "chore: apply fork overlay from .farhoodlabs"
# --- PRs to cherry-pick commit-by-commit (clean, no merge commits) ---
# Format: "PR-number branch-name [exclude:base-branch]"
# Use exclude: when a branch was rebased onto another PR branch to avoid double-applying commits.
# Remove an entry here when upstream merges the PR.
PR_CHERRY_PICK=(
"3237 skill-pat-feature"
"3351 skill-scan-refresh exclude:skill-pat-feature"
"4162 fix/far-108-k8s-adapter-reaper-liveness"
)
for entry in "${PR_CHERRY_PICK[@]}"; do
# Parse: pr_num, branch, optional exclude branch
pr_num=$(echo "$entry" | awk '{print $1}')
branch=$(echo "$entry" | awk '{print $2}')
exclude_branch=$(echo "$entry" | grep -oP '(?<=exclude:)\S+' || true)
remote_branch="origin/$branch"
exclude_arg=""
if [ -n "$exclude_branch" ]; then
exclude_arg="--not origin/$exclude_branch"
fi
if ! git rev-parse "$remote_branch" &>/dev/null; then
echo "WARNING: $remote_branch not found, skipping PR #$pr_num"
continue
fi
# Exclude commits already on origin/master (fork-overlay/CI infra
# that landed on master via the .farhoodlabs/ overlay path). PR
# branches sometimes pull these in via `git merge origin/master`,
# but cherry-picking them onto `local` (which is already master)
# is redundant and produces conflicts on the assemble-local file.
mapfile -t commits < <(git log --no-merges --reverse --format="%H" upstream/master.."$remote_branch" ^origin/master $exclude_arg)
if [ ${#commits[@]} -eq 0 ]; then
echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping"
continue
fi
echo "PR #$pr_num ($branch): cherry-picking ${#commits[@]} commit(s)"
for sha in "${commits[@]}"; do
git cherry-pick "$sha" || {
# If the cherry-pick produced an empty result (commit's content
# is already in HEAD via auto-merge), skip it instead of failing.
# State signature: CHERRY_PICK_HEAD set, no unmerged paths,
# nothing staged.
if [ -f .git/CHERRY_PICK_HEAD ] \
&& [ -z "$(git diff --name-only --diff-filter=U)" ] \
&& git diff --staged --quiet; then
echo "PR #$pr_num: $sha became empty after merge, skipping"
git cherry-pick --skip
continue
fi
echo "::error::Cherry-pick conflict at $sha from PR #$pr_num ($branch)"
echo "::error::Resolve the conflict, force-push the branch, then re-run this workflow"
git cherry-pick --abort
exit 1
}
done
done
# --- PRs to apply as a single squash (complex history with merge commits) ---
# git merge --squash applies the net final diff of the branch, bypassing
# intra-PR commit ordering issues. CI commits that cancel out are ignored.
# Remove an entry here when upstream merges the PR.
PR_SQUASH=(
"3987 feat/company-portability-complete"
)
for entry in "${PR_SQUASH[@]}"; do
pr_num="${entry%% *}"
branch="${entry#* }"
remote_branch="origin/$branch"
if ! git rev-parse "$remote_branch" &>/dev/null; then
echo "WARNING: $remote_branch not found, skipping PR #$pr_num"
continue
fi
# Check if the branch has any unique non-merge commits
unique=$(git log --no-merges --oneline upstream/master.."$remote_branch" | wc -l)
if [ "$unique" -eq 0 ]; then
echo "PR #$pr_num ($branch): no unique commits — likely merged upstream, skipping"
continue
fi
echo "PR #$pr_num ($branch): applying as squash ($unique non-merge commits)"
git merge --squash "$remote_branch" || {
echo "::error::Squash conflict for PR #$pr_num ($branch)"
git merge --abort 2>/dev/null || git reset --hard HEAD
exit 1
}
# Only commit if there are staged changes
git diff --staged --quiet || git commit -m "feat: apply PR #$pr_num ($branch)"
done
git push origin local --force
echo "local branch assembled and pushed"
- name: Trigger prod build
run: |
curl -sS -X POST \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/actions/workflows/build-prod.yml/dispatches \
-d '{"ref":"local"}'
- name: Sync build-dev.yml to dev branch
run: |
set -euo pipefail
if ! git rev-parse origin/dev &>/dev/null; then
echo "dev branch not found on origin, skipping"
exit 0
fi
canonical=".farhoodlabs/.github/workflows/build-dev.yml"
target=".github/workflows/build-dev.yml"
if git show origin/dev:"$target" 2>/dev/null | diff --brief - "$canonical" &>/dev/null; then
echo "build-dev.yml on dev is up to date, skipping"
exit 0
fi
echo "Syncing build-dev.yml to dev branch..."
# Save canonical content before switching branches (.farhoodlabs/ only exists on master)
tmp=$(mktemp)
cp "$canonical" "$tmp"
git checkout -B dev-wf-sync origin/dev
mkdir -p "$(dirname "$target")"
cp "$tmp" "$target"
rm "$tmp"
git add "$target"
git commit -m "chore(ci): sync build-dev.yml from .farhoodlabs"
git push origin dev-wf-sync:dev
echo "build-dev.yml synced to dev"
+93
View File
@@ -0,0 +1,93 @@
name: "Build: Dev"
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: runners-farhoodlabs
timeout-minutes: 30
outputs:
image-tag: ${{ steps.tag.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image tag
id: tag
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/farhoodlabs/paperclip-dev
tags: |
type=raw,value=latest
type=sha,prefix=
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: .farhoodlabs/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
update-infra:
needs: build
runs-on: runners-farhoodlabs
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.PAPERCLIP_APP_ID }}
private-key: ${{ secrets.PAPERCLIP_APP_PRIVATE_KEY }}
repositories: paperclip-infra
- name: Update dev image tag in infra repo
run: |
SHA="${{ needs.build.outputs.image-tag }}"
FILE="overlays/dev/kustomization.yaml"
response=$(curl -sS \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE")
file_sha=$(echo "$response" | jq -r '.sha')
content=$(echo "$response" | jq -r '.content' | base64 -d)
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
encoded=$(printf '%s' "$new_content" | base64 -w 0)
curl -sS -X PUT \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
+53
View File
@@ -0,0 +1,53 @@
name: "Build: Production"
on:
push:
branches: [local]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: runners-farhoodlabs
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/farhoodlabs/paperclip
tags: |
type=raw,value=latest
type=sha,prefix=
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
+14 -50
View File
@@ -1,55 +1,19 @@
# Disabled in fork — Docker builds are handled by the fork overlay:
# build-prod.yml triggers on `local` branch → ghcr.io/farhoodlabs/paperclip
# build-dev.yml triggers on `dev` branch → ghcr.io/farhoodlabs/paperclip-dev
# See .farhoodlabs/.github/workflows/ and .github/workflows/assemble-local.yml
#
# NOTE: upstream may overwrite this file when master is synced. Re-apply if that happens,
# or use the sync-upstream.yml action which re-applies these overrides automatically.
name: Docker
on:
push:
branches:
- "master"
tags:
- "v*"
permissions:
contents: read
packages: write
workflow_dispatch:
inputs:
note:
description: "Disabled in fork. Use build-prod.yml (local) or build-dev.yml (dev)."
required: false
jobs:
build-and-push:
disabled:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- run: echo "Disabled. See build-prod.yml and build-dev.yml."
+7 -2
View File
@@ -23,7 +23,9 @@ jobs:
- name: Block manual lockfile edits
if: github.head_ref != 'chore/refresh-lockfile'
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
# Diff the PR branch against its merge base so recent base-branch commits
# do not masquerade as changes made by the PR itself.
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
exit 1
@@ -45,7 +47,7 @@ jobs:
- name: Validate dependency resolution when manifests change
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
@@ -80,6 +82,9 @@ jobs:
- name: Run tests
run: pnpm test:run
- name: Verify release registry test coverage
run: pnpm run test:release-registry
- name: Build
run: pnpm build
+8 -253
View File
@@ -1,261 +1,16 @@
# Disabled in fork — package publishing is not applicable to this fork.
#
# NOTE: upstream may overwrite this file when master is synced. Re-apply if that happens,
# or use the sync-upstream.yml action which re-applies these overrides automatically.
name: Release
on:
push:
branches:
- master
workflow_dispatch:
inputs:
source_ref:
description: Commit SHA, branch, or tag to publish as stable
required: true
type: string
default: master
stable_date:
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
note:
description: "Disabled in fork."
required: false
type: string
dry_run:
description: Preview the stable release without publishing
required: true
type: boolean
default: false
concurrency:
group: release-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify_canary:
if: github.event_name == 'push'
disabled:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
publish_canary:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-canary
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish canary
env:
GITHUB_ACTIONS: "true"
run: ./scripts/release.sh canary --skip-verify
- name: Push canary tag
run: |
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no canary tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
verify_stable:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
preview_stable:
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Dry-run stable release
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify --dry-run)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
publish_stable:
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-stable
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_REMOTE: origin
run: |
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
if [ -z "$version" ]; then
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"
- run: echo "Disabled in fork."
+70
View File
@@ -0,0 +1,70 @@
name: Sync upstream
# Syncs upstream/master into this fork's master, then re-applies fork overrides
# for any upstream workflow files that should not run in the fork (docker.yml, release.yml).
# Triggers assemble-local.yml automatically via the master push.
#
# Run manually or on a schedule to keep master current with upstream.
on:
schedule:
- cron: '0 7 * * *' # daily at 2am EST (UTC-5)
workflow_dispatch:
permissions:
contents: write
jobs:
sync:
runs-on: runners-farhoodlabs
timeout-minutes: 10
steps:
- name: Checkout master
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Fetch upstream
run: |
git remote add upstream https://github.com/paperclipai/paperclip.git 2>/dev/null || true
git fetch upstream
- name: Fast-forward master to upstream
run: |
git merge --ff-only upstream/master || {
echo "::error::Cannot fast-forward master to upstream/master — diverged history"
echo "::error::Resolve manually: git fetch upstream && git rebase upstream/master"
exit 1
}
- name: Re-apply fork workflow overrides
run: |
# These files are overridden in the fork to prevent upstream workflows from
# running on fork pushes. Re-apply after each upstream sync.
OVERRIDE_FILES=(
".github/workflows/docker.yml"
".github/workflows/release.yml"
)
changed=false
for f in "${OVERRIDE_FILES[@]}"; do
fork_version=$(git show origin/master:"$f" 2>/dev/null || true)
current=$(cat "$f" 2>/dev/null || true)
if [ "$fork_version" != "$current" ]; then
echo "Re-applying fork override: $f"
git checkout origin/master -- "$f"
changed=true
fi
done
if [ "$changed" = true ]; then
git add "${OVERRIDE_FILES[@]}"
git commit -m "chore(ci): re-apply fork workflow overrides after upstream sync"
fi
- name: Push master
run: git push origin master
+80
View File
@@ -0,0 +1,80 @@
# Paperclip Fork — Project Context
This is a fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
Fork repo: https://github.com/farhoodlabs/paperclip
## Branch Model
| Branch | Purpose |
|---|---|
| `master` | Mirrors `upstream/master` exactly + `.farhoodlabs/` overlay directory + `assemble-local.yml` action. Never commit application code here. |
| `local` | **Default branch.** Assembled automatically by `assemble-local.yml` on every `master` push. Contains: upstream + fork Dockerfile/workflows + all pending upstream PR cherry-picks. Builds `ghcr.io/farhoodlabs/paperclip`. |
| `dev` | Development branch based on upstream/master. Builds `ghcr.io/farhoodlabs/paperclip-dev` on every push. |
| PR branches | `skill-pat-feature`, `skill-scan-refresh`, `feat/company-portability-complete`, `fix/far-108-k8s-adapter-reaper-liveness` — open PRs to upstream, never rebase onto master/local. |
**Never commit directly to `local`** — it is fully regenerated by the assemble action and any direct commits will be overwritten.
## Fork Overlay (`.farhoodlabs/`)
Files committed to `master` that get copied into position on `local` by the assemble action:
```
.farhoodlabs/
CLAUDE.md → CLAUDE.md (repo root)
Dockerfile → Dockerfile
.github/workflows/build-prod.yml → .github/workflows/build-prod.yml
.github/workflows/build-dev.yml → .github/workflows/build-dev.yml
```
The fork's Dockerfile production stage additions over upstream: `kubectl`, `kubeseal`, `uv`/`uvx`, `forgejo-cli` (`fj`, `fj-ex`, `fgj`), `nano`, `vim`.
To modify fork-specific files, edit them in `.farhoodlabs/` on `master` and push — the assemble action will apply them to `local` automatically.
## Pending Upstream PRs (included in `local`)
These are cherry-picked/squashed onto `local` by the assemble action. When upstream merges one, remove its entry from `assemble-local.yml`.
| PR | Branch | Method | Notes |
|---|---|---|---|
| #3237 | `skill-pat-feature` | cherry-pick | GitHub PAT support for private skill repos |
| #3351 | `skill-scan-refresh` | cherry-pick (exclude: skill-pat-feature) | Rebased onto skill-pat-feature |
| #3987 | `feat/company-portability-complete` | squash | Secrets export/import; squashed to bypass intra-PR merge commits |
| #4162 | `fix/far-108-k8s-adapter-reaper-liveness` | cherry-pick | K8s adapter reaper liveness |
## Common Tasks
### Sync upstream into master
```bash
git fetch upstream
git push origin upstream/master:master --force-with-lease
# assemble-local.yml triggers automatically and rebuilds local
```
### Add a new pending PR to local
Edit `.github/workflows/assemble-local.yml` on `master`:
- Simple PR (clean commits, no merge commits): add to `PR_CHERRY_PICK`
- Complex PR (has merge commits mixed in): add to `PR_SQUASH`
- If the branch was rebased onto another PR branch: use `exclude:base-branch`
### Remove a PR after upstream merges it
Delete its entry from `PR_CHERRY_PICK` or `PR_SQUASH` in `assemble-local.yml` on `master`.
### Submit a new PR to upstream
Branch from `upstream/master` (not from `local` or `master`):
```bash
git fetch upstream
git checkout -b feat/my-feature upstream/master
```
### Modify the fork Dockerfile
Edit `.farhoodlabs/Dockerfile` on `master`. Only modify the production stage — keep base/deps/build stages identical to upstream so diffs are minimal and upstream changes apply cleanly.
## Deployment
Paperclip runs in Kubernetes, not locally. Use `kubectl` to access it. The production image is `ghcr.io/farhoodlabs/paperclip:latest`.
## Key Files
- `.github/workflows/assemble-local.yml` — assembles `local` branch; edit this to manage pending PRs
- `.farhoodlabs/` — fork overlay; all fork-specific files live here on `master`
- `server/package.json` — has an adapter-utils workspace vs canary hack that needs fixing eventually
+1
View File
@@ -22,6 +22,7 @@ COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
COPY packages/adapter-utils/package.json packages/adapter-utils/
COPY packages/mcp-server/package.json packages/mcp-server/
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
+1
View File
@@ -37,6 +37,7 @@
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"@paperclipai/adapter-acpx-local": "workspace:*",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
+7
View File
@@ -1,4 +1,5 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
@@ -14,6 +15,11 @@ const claudeLocalCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printClaudeStreamEvent,
};
const acpxLocalCLIAdapter: CLIAdapterModule = {
type: "acpx_local",
formatStdoutEvent: printAcpxStreamEvent,
};
const codexLocalCLIAdapter: CLIAdapterModule = {
type: "codex_local",
formatStdoutEvent: printCodexStreamEvent,
@@ -46,6 +52,7 @@ const openclawGatewayCLIAdapter: CLIAdapterModule = {
const adaptersByType = new Map<string, CLIAdapterModule>(
[
acpxLocalCLIAdapter,
claudeLocalCLIAdapter,
codexLocalCLIAdapter,
openCodeLocalCLIAdapter,
+9 -1
View File
@@ -149,7 +149,15 @@ The plugin runtime tracks plugin-owned database namespaces and migrations in `pl
## Backups
Paperclip supports automatic and manual database backups. See `doc/DEVELOPING.md` for the current `paperclipai db:backup` / `pnpm db:backup` commands and backup retention configuration.
Paperclip supports automatic and manual logical database backups. These dumps include
non-system database schemas such as `public`, the Drizzle migration journal, and
plugin-owned database schemas. See `doc/DEVELOPING.md` for the current
`paperclipai db:backup` / `pnpm db:backup` commands and backup retention
configuration.
Database backups do not include non-database instance files such as local-disk
uploads, workspace files, or the local encrypted secrets master key. Back those paths
up separately when you need full instance disaster recovery.
## Secret storage
+7 -1
View File
@@ -421,7 +421,9 @@ If you set `DATABASE_URL`, the server will use that instead of embedded PostgreS
## Automatic DB Backups
Paperclip can run automatic DB backups on a timer. Defaults:
Paperclip can run automatic logical database backups on a timer. These backups cover
non-system database schemas, including migration history and plugin-owned database
schemas. Defaults:
- enabled
- every 60 minutes
@@ -449,6 +451,10 @@ Environment overrides:
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
DB backups are not full instance filesystem backups. For full local disaster
recovery, also back up local storage files and the local encrypted secrets key if
those providers are enabled.
## Secrets in Dev
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
+7
View File
@@ -143,6 +143,13 @@ This keeps the default install path unchanged while allowing explicit installs w
npx paperclipai@canary onboard
```
The release script now verifies two things after a canary publish:
- the `canary` dist-tag resolves to the version that was just published
- every published internal `@paperclipai/*` dependency referenced by that manifest exists on npm
It also treats `latest -> canary` as a failure by default, because npm metadata can otherwise leave the default install path pointing at an unreleased canary dependency graph. Only pass `./scripts/release.sh canary --allow-canary-latest` when that `latest` behavior is explicitly intended.
### Stable
Stable publishes use the npm dist-tag `latest`.
+2
View File
@@ -63,6 +63,8 @@ It:
- verifies the pushed commit
- computes the canary version for the current UTC date
- publishes under npm dist-tag `canary`
- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm
- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
Users install canaries with:
+2 -2
View File
@@ -150,7 +150,7 @@ Invariant: every business record belongs to exactly one company.
- `capabilities` text null
- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway`
- `adapter_config` jsonb not null
- `runtime_config` jsonb not null default `{}`
- `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config
- `default_environment_id` uuid fk `environments.id` null
- `context_mode` enum: `thin | fat` default `thin`
- `budget_monthly_cents` int not null default 0
@@ -676,7 +676,7 @@ Per-agent schedule fields in `adapter_config`:
- `enabled` boolean
- `intervalSec` integer (minimum 30)
- `maxConcurrentRuns` integer; new agents default to `5`
- `maxConcurrentRuns` integer; new agents default to `20`; scheduler clamps configured values to `1..50`
Scheduler must skip invocation when:
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+4 -1
View File
@@ -35,13 +35,16 @@
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.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",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs",
"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:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts"
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts",
"perf:issue-chat-long-thread": "node scripts/measure-issue-chat-long-thread.mjs"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
@@ -0,0 +1,128 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
const execFile = promisify(execFileCallback);
describe("command managed runtime", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("keeps the runtime overlay out of sandbox workspace sync by default", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(path.join(localWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
await writeFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "{\"keep\":true}\n", "utf8");
const calls: Array<{
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}> = [];
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}): Promise<RunProcessResult> => {
calls.push({ ...input });
const startedAt = new Date().toISOString();
const env = {
...process.env,
...input.env,
};
const command = input.command === "sh" ? "/bin/sh" : input.command;
const args = [...(input.args ?? [])];
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
}
try {
const result = await execFile(command, args, {
cwd: input.cwd,
env,
maxBuffer: 32 * 1024 * 1024,
timeout: input.timeoutMs,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
code?: string | number | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
};
return {
exitCode: typeof err.code === "number" ? err.code : null,
signal: err.signal ?? null,
timedOut: Boolean(err.killed && input.timeoutMs),
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
pid: null,
startedAt,
};
}
},
};
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "claude",
workspaceLocalDir: localWorkspaceDir,
});
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
await expect(readFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
expect(calls.every((call) => call.stdin == null)).toBe(true);
await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8");
await writeFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "remote-state.json"), "{\"remote\":true}\n", "utf8");
await prepared.restoreWorkspace();
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves
.toBe("{\"keep\":true}\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "remote-state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
expect(calls.every((call) => call.stdin == null)).toBe(true);
});
});
@@ -35,6 +35,12 @@ function shellQuote(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function mergeRuntimeExcludes(entries: string[] | undefined): string[] {
return [...new Set([".paperclip-runtime", ...(entries ?? [])])];
}
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
if (Buffer.isBuffer(bytes)) return bytes;
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
@@ -48,7 +54,7 @@ function requireSuccessfulResult(result: RunProcessResult, action: string): void
throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`);
}
function createCommandManagedRuntimeClient(input: {
export function createCommandManagedRuntimeClient(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
timeoutMs: number;
@@ -71,15 +77,39 @@ function createCommandManagedRuntimeClient(input: {
},
writeFile: async (remotePath, bytes) => {
const body = toBuffer(bytes).toString("base64");
const remoteDir = path.posix.dirname(remotePath);
const remoteTempPath = `${remotePath}.paperclip-upload.b64`;
await runShell(
`mkdir -p ${shellQuote(path.posix.dirname(remotePath))} && base64 -d > ${shellQuote(remotePath)}`,
{ stdin: body },
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`,
);
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
const chunk = body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE);
await runShell(`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(remoteTempPath)}`);
}
await runShell(
`base64 -d < ${shellQuote(remoteTempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(remoteTempPath)}`,
);
},
readFile: async (remotePath) => {
const result = await runShell(`base64 < ${shellQuote(remotePath)}`);
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64");
},
listFiles: async (remotePath) => {
const result = await runShell(
`if [ -d ${shellQuote(remotePath)} ]; then ` +
`for entry in ${shellQuote(remotePath)}/*; do ` +
`[ -f "$entry" ] || continue; ` +
`basename "$entry"; ` +
`done; ` +
`fi`,
);
return result.stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.sort((left, right) => left.localeCompare(right));
},
remove: async (remotePath) => {
const result = await input.runner.execute({
command: "sh",
@@ -145,7 +175,7 @@ export async function prepareCommandManagedRuntime(input: {
adapterKey: input.adapterKey,
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir,
workspaceExclude: input.workspaceExclude,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
});
@@ -0,0 +1,21 @@
export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***";
const COMMAND_CLI_SECRET_OPTION_RE =
/(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi;
const COMMAND_ENV_SECRET_ASSIGNMENT_RE =
/(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi;
const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
const COMMAND_JWT_RE =
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
return command
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
.replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`)
.replace(COMMAND_OPENAI_KEY_RE, redactedValue)
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
.replace(COMMAND_JWT_RE, redactedValue);
}
@@ -1,14 +1,59 @@
import { describe, expect, it, vi } from "vitest";
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetToRemoteSpec,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
type AdapterSandboxExecutionTarget,
} from "./execution-target.js";
import { runChildProcess } from "./server-utils.js";
describe("sandbox adapter execution targets", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
function createLocalSandboxRunner() {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
return runChildProcess(`sandbox-run-${counter}`, input.command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
onSpawn: input.onSpawn
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
: undefined,
});
},
};
}
it("executes through the provider-neutral runner without a remote spec", async () => {
const runner = {
execute: vi.fn(async () => ({
@@ -58,6 +103,7 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd: "/workspace",
paperclipTransport: "bridge",
});
});
@@ -93,4 +139,154 @@ describe("sandbox adapter execution targets", () => {
timeoutMs: 7000,
}));
});
it("starts a localhost Paperclip bridge for sandbox targets in bridge mode", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
const apiServer = createServer((req, res) => {
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
auth: req.headers.authorization ?? null,
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve, reject) => {
apiServer.once("error", reject);
apiServer.listen(0, "127.0.0.1", () => resolve());
});
const address = apiServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected the bridge test API server to listen on a TCP port.");
}
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge",
target,
runtimeRootDir,
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
});
try {
expect(bridge).not.toBeNull();
expect(bridge?.env.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(bridge?.env.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
accept: "application/json",
},
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(requests).toEqual([{
method: "GET",
url: "/api/agents/me",
auth: "Bearer real-run-jwt",
runId: "run-bridge",
}]);
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
it("fails oversized host responses with a 502 before returning them to the sandbox client", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-limit-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
const largeBody = "x".repeat(64);
const apiServer = createServer((req, res) => {
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
auth: req.headers.authorization ?? null,
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
});
res.writeHead(200, {
"content-type": "application/json",
"content-length": String(Buffer.byteLength(largeBody, "utf8")),
});
res.end(largeBody);
});
await new Promise<void>((resolve, reject) => {
apiServer.once("error", reject);
apiServer.listen(0, "127.0.0.1", () => resolve());
});
const address = apiServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected the bridge test API server to listen on a TCP port.");
}
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge-limit",
target,
runtimeRootDir,
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
maxBodyBytes: 32,
});
try {
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
accept: "application/json",
},
});
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Bridge response body exceeded the configured size limit of 32 bytes.",
});
expect(requests).toEqual([{
method: "GET",
url: "/api/agents/me",
auth: "Bearer real-run-jwt",
runId: "run-bridge-limit",
}]);
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
});
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import * as ssh from "./ssh.js";
import {
adapterExecutionTargetUsesManagedHome,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetShellCommand,
} from "./execution-target.js";
@@ -159,3 +160,49 @@ describe("runAdapterExecutionTargetShellCommand", () => {
})).toBe(false);
});
});
describe("resolveAdapterExecutionTargetCwd", () => {
const sshTarget = {
kind: "remote" as const,
transport: "ssh" as const,
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
};
it("falls back to the remote cwd when no adapter cwd is configured", () => {
expect(resolveAdapterExecutionTargetCwd(sshTarget, "", "/Users/host/repo/server")).toBe(
"/srv/paperclip/workspace",
);
expect(resolveAdapterExecutionTargetCwd(sshTarget, " ", "/Users/host/repo/server")).toBe(
"/srv/paperclip/workspace",
);
expect(resolveAdapterExecutionTargetCwd(sshTarget, null, "/Users/host/repo/server")).toBe(
"/srv/paperclip/workspace",
);
});
it("preserves an explicit adapter cwd when one is configured", () => {
expect(
resolveAdapterExecutionTargetCwd(
sshTarget,
"/srv/paperclip/custom-agent-dir",
"/Users/host/repo/server",
),
).toBe("/srv/paperclip/custom-agent-dir");
});
it("keeps the local fallback cwd for local targets", () => {
expect(resolveAdapterExecutionTargetCwd(null, "", "/Users/host/repo/server")).toBe(
"/Users/host/repo/server",
);
});
});
+295 -1
View File
@@ -10,6 +10,14 @@ import {
remoteExecutionSessionMatches,
type RemoteManagedRuntimeAsset,
} from "./remote-managed-runtime.js";
import {
createCommandManagedSandboxCallbackBridgeQueueClient,
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js";
import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
import {
ensureCommandResolvable,
@@ -43,6 +51,7 @@ export interface AdapterSandboxExecutionTarget {
leaseId?: string | null;
remoteCwd: string;
paperclipApiUrl?: string | null;
paperclipTransport?: "direct" | "bridge";
timeoutMs?: number | null;
runner?: CommandManagedRuntimeRunner;
}
@@ -82,6 +91,11 @@ export interface AdapterExecutionTargetShellOptions {
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}
export interface AdapterExecutionTargetPaperclipBridgeHandle {
env: Record<string, string>;
stop(): Promise<void>;
}
function parseObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -96,6 +110,31 @@ function readStringMeta(parsed: Record<string, unknown>, key: string): string |
return readString(parsed[key]);
}
function resolveHostForUrl(rawHost: string): string {
const host = rawHost.trim();
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
return host;
}
function resolveDefaultPaperclipApiUrl(): string {
const runtimeHost = resolveHostForUrl(
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
);
// 3100 matches the default Paperclip dev server port when the runtime does not provide one.
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
return `http://${runtimeHost}:${runtimePort}`;
}
function resolveSandboxPaperclipTransport(
target: Pick<AdapterSandboxExecutionTarget, "paperclipTransport" | "paperclipApiUrl">,
): "direct" | "bridge" {
if (target.paperclipTransport === "direct" || target.paperclipTransport === "bridge") {
return target.paperclipTransport;
}
return target.paperclipApiUrl ? "direct" : "bridge";
}
function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
const parsed = parseObject(value);
if (parsed.kind === "local") return true;
@@ -130,14 +169,34 @@ export function adapterExecutionTargetRemoteCwd(
return target?.kind === "remote" ? target.remoteCwd : localCwd;
}
export function resolveAdapterExecutionTargetCwd(
target: AdapterExecutionTarget | null | undefined,
configuredCwd: string | null | undefined,
localFallbackCwd: string,
): string {
if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) {
return configuredCwd;
}
return adapterExecutionTargetRemoteCwd(target, localFallbackCwd);
}
export function adapterExecutionTargetPaperclipApiUrl(
target: AdapterExecutionTarget | null | undefined,
): string | null {
if (target?.kind !== "remote") return null;
if (target.transport === "ssh") return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null;
if (resolveSandboxPaperclipTransport(target) === "bridge") return null;
return target.paperclipApiUrl ?? null;
}
export function adapterExecutionTargetUsesPaperclipBridge(
target: AdapterExecutionTarget | null | undefined,
): boolean {
return target?.kind === "remote" &&
target.transport === "sandbox" &&
resolveSandboxPaperclipTransport(target) === "bridge";
}
export function describeAdapterExecutionTarget(
target: AdapterExecutionTarget | null | undefined,
): string {
@@ -336,18 +395,78 @@ export async function ensureAdapterExecutionTargetFile(
);
}
/**
* Ensure a working directory exists (and is a directory) on the execution target.
*
* For local targets this delegates to the local `ensureAbsoluteDirectory` helper
* (Node fs). For remote (SSH/sandbox) targets it shells out and runs
* `mkdir -p` (when allowed) followed by a `[ -d ]` check so the result reflects
* the directory state inside the environment, not on the Paperclip host.
*
* Throws an Error with a human-readable message on failure.
*/
export async function ensureAdapterExecutionTargetDirectory(
runId: string,
target: AdapterExecutionTarget | null | undefined,
cwd: string,
options: AdapterExecutionTargetShellOptions & { createIfMissing?: boolean },
): Promise<void> {
const createIfMissing = options.createIfMissing ?? false;
if (!target || target.kind === "local") {
const { ensureAbsoluteDirectory } = await import("./server-utils.js");
await ensureAbsoluteDirectory(cwd, { createIfMissing });
return;
}
// Remote (SSH or sandbox): both expect POSIX absolute paths inside the env.
if (!cwd.startsWith("/")) {
throw new Error(`Working directory must be an absolute POSIX path on the remote target: "${cwd}"`);
}
const quoted = shellQuote(cwd);
const script = createIfMissing
? `mkdir -p ${quoted} && [ -d ${quoted} ]`
: `[ -d ${quoted} ]`;
const result = await runAdapterExecutionTargetShellCommand(runId, target, script, {
cwd: target.kind === "remote" ? target.remoteCwd : cwd,
env: options.env,
timeoutSec: options.timeoutSec ?? 15,
graceSec: options.graceSec ?? 5,
onLog: options.onLog,
});
if (result.timedOut) {
throw new Error(`Timed out checking working directory on remote target: "${cwd}"`);
}
if ((result.exitCode ?? 1) !== 0) {
const detail = (result.stderr || result.stdout || "").trim();
if (createIfMissing) {
throw new Error(
`Could not create working directory "${cwd}" on remote target${detail ? `: ${detail}` : "."}`,
);
}
throw new Error(
`Working directory does not exist on remote target: "${cwd}"${detail ? ` (${detail})` : ""}`,
);
}
}
export function adapterExecutionTargetSessionIdentity(
target: AdapterExecutionTarget | null | undefined,
): Record<string, unknown> | null {
if (!target || target.kind === "local") return null;
if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec);
const paperclipTransport = resolveSandboxPaperclipTransport(target);
return {
transport: "sandbox",
providerKey: target.providerKey ?? null,
environmentId: target.environmentId ?? null,
leaseId: target.leaseId ?? null,
remoteCwd: target.remoteCwd,
...(target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
paperclipTransport,
...(paperclipTransport === "direct" && target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
};
}
@@ -367,6 +486,7 @@ export function adapterExecutionTargetSessionMatches(
readStringMeta(parsedSaved, "environmentId") === current?.environmentId &&
readStringMeta(parsedSaved, "leaseId") === current?.leaseId &&
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd &&
readStringMeta(parsedSaved, "paperclipTransport") === (current?.paperclipTransport ?? null) &&
readStringMeta(parsedSaved, "paperclipApiUrl") === (current?.paperclipApiUrl ?? null)
);
}
@@ -399,6 +519,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
const remoteCwd = readStringMeta(parsed, "remoteCwd");
const paperclipTransport = readStringMeta(parsed, "paperclipTransport");
if (!remoteCwd) return null;
return {
kind: "remote",
@@ -408,6 +529,10 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
leaseId: readStringMeta(parsed, "leaseId"),
remoteCwd,
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"),
paperclipTransport:
paperclipTransport === "direct" || paperclipTransport === "bridge"
? paperclipTransport
: undefined,
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
};
}
@@ -514,3 +639,172 @@ export function runtimeAssetDir(
): string {
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
}
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
const out: Record<string, string> = {};
for (const key of ["content-type", "etag", "last-modified"]) {
const value = response.headers.get(key);
if (value && value.trim().length > 0) out[key] = value.trim();
}
return out;
}
function buildBridgeForwardUrl(baseUrl: string, request: { path: string; query: string }): URL {
const url = new URL(request.path, baseUrl);
const query = request.query.trim();
url.search = query.startsWith("?") ? query.slice(1) : query;
return url;
}
function bridgeResponseBodyLimitError(maxBodyBytes: number): Error {
return new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
}
async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise<string> {
const rawContentLength = response.headers.get("content-length");
if (rawContentLength) {
const contentLength = Number.parseInt(rawContentLength, 10);
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
throw bridgeResponseBodyLimitError(maxBodyBytes);
}
}
if (!response.body) {
return "";
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
await reader.cancel().catch(() => undefined);
throw bridgeResponseBodyLimitError(maxBodyBytes);
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
}
export async function startAdapterExecutionTargetPaperclipBridge(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
runtimeRootDir: string | null | undefined;
adapterKey: string;
hostApiToken: string | null | undefined;
hostApiUrl?: string | null;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
maxBodyBytes?: number | null;
}): Promise<AdapterExecutionTargetPaperclipBridgeHandle | null> {
if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) {
return null;
}
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
return null;
}
const target = input.target;
const onLog = input.onLog ?? (async () => {});
const hostApiToken = input.hostApiToken?.trim() ?? "";
if (hostApiToken.length === 0) {
throw new Error("Sandbox bridge mode requires a host-side Paperclip API token.");
}
const runtimeRootDir =
input.runtimeRootDir?.trim().length
? input.runtimeRootDir.trim()
: path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey);
const bridgeRuntimeDir = path.posix.join(runtimeRootDir, "paperclip-bridge");
const queueDir = path.posix.join(bridgeRuntimeDir, "queue");
const assetRemoteDir = path.posix.join(bridgeRuntimeDir, "server");
const bridgeToken = createSandboxCallbackBridgeToken();
const maxBodyBytes =
typeof input.maxBodyBytes === "number" && Number.isFinite(input.maxBodyBytes) && input.maxBodyBytes > 0
? Math.trunc(input.maxBodyBytes)
: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES;
const hostApiUrl =
input.hostApiUrl?.trim() ||
process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ||
process.env.PAPERCLIP_API_URL?.trim() ||
resolveDefaultPaperclipApiUrl();
await onLog(
"stdout",
`[paperclip] Starting sandbox callback bridge for ${input.adapterKey} in ${bridgeRuntimeDir}.\n`,
);
const bridgeAsset = await createSandboxCallbackBridgeAsset();
let server: Awaited<ReturnType<typeof startSandboxCallbackBridgeServer>> | null = null;
let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null;
try {
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: requireSandboxRunner(target),
remoteCwd: target.remoteCwd,
timeoutMs: target.timeoutMs,
});
worker = await startSandboxCallbackBridgeWorker({
client,
queueDir,
maxBodyBytes,
handleRequest: async (request) => {
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (value.trim().length === 0) continue;
headers.set(key, value);
}
headers.set("authorization", `Bearer ${hostApiToken}`);
headers.set("x-paperclip-run-id", input.runId);
const method = request.method.trim().toUpperCase() || "GET";
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
method,
headers,
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
signal: AbortSignal.timeout(30_000),
});
return {
status: response.status,
headers: buildBridgeResponseHeaders(response),
body: await readBridgeForwardResponseBody(response, maxBodyBytes),
};
},
});
server = await startSandboxCallbackBridgeServer({
runner: requireSandboxRunner(target),
remoteCwd: target.remoteCwd,
assetRemoteDir,
queueDir,
bridgeToken,
bridgeAsset,
timeoutMs: target.timeoutMs,
maxBodyBytes,
});
} catch (error) {
await Promise.allSettled([
server?.stop(),
worker?.stop(),
bridgeAsset.cleanup(),
]);
throw error;
}
return {
env: {
PAPERCLIP_API_URL: server.baseUrl,
PAPERCLIP_API_KEY: bridgeToken,
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {
await Promise.allSettled([
server?.stop(),
]);
await Promise.allSettled([
worker?.stop(),
bridgeAsset.cleanup(),
]);
},
};
}
+18
View File
@@ -20,6 +20,8 @@ export type {
AdapterSkillContext,
AdapterSessionCodec,
AdapterModel,
AdapterModelProfileKey,
AdapterModelProfileDefinition,
HireApprovedPayload,
HireApprovedHookResult,
ConfigFieldOption,
@@ -53,4 +55,20 @@ export {
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";
export {
REDACTED_COMMAND_TEXT_VALUE,
redactCommandText,
} from "./command-redaction.js";
export { inferOpenAiCompatibleBiller } from "./billing.js";
// Keep the root adapter-utils entry browser-safe because the UI imports it.
// The sandbox callback bridge stays available via its dedicated subpath export.
export type {
SandboxCallbackBridgeRequest,
SandboxCallbackBridgeResponse,
SandboxCallbackBridgeAsset,
SandboxCallbackBridgeDirectories,
SandboxCallbackBridgeRouteRule,
SandboxCallbackBridgeQueueClient,
SandboxCallbackBridgeWorkerHandle,
StartedSandboxCallbackBridgeServer,
} from "./sandbox-callback-bridge.js";
@@ -0,0 +1,610 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
import {
createFileSystemSandboxCallbackBridgeQueueClient,
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
sandboxCallbackBridgeDirectories,
startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js";
import type { RunProcessResult } from "./server-utils.js";
const execFile = promisify(execFileCallback);
describe("sandbox callback bridge", () => {
const cleanupDirs: string[] = [];
const cleanupFns: Array<() => Promise<void>> = [];
function createExecRunner() {
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}): Promise<RunProcessResult> => {
const startedAt = new Date().toISOString();
const env = {
...process.env,
...input.env,
};
const command = input.command === "sh" ? "/bin/sh" : input.command;
const args = [...(input.args ?? [])];
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
}
try {
const result = await execFile(command, args, {
cwd: input.cwd,
env,
maxBuffer: 32 * 1024 * 1024,
timeout: input.timeoutMs,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
code?: string | number | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
};
return {
exitCode: typeof err.code === "number" ? err.code : null,
signal: err.signal ?? null,
timedOut: Boolean(err.killed && input.timeoutMs),
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
pid: null,
startedAt,
};
}
},
};
}
async function waitForJsonFile(directory: string, timeoutMs = 2_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const entries = await readdir(directory).catch(() => []);
const match = entries.find((entry) => entry.endsWith(".json"));
if (match) return match;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for a JSON file in ${directory}.`);
}
afterEach(async () => {
while (cleanupFns.length > 0) {
const cleanup = cleanupFns.pop();
if (!cleanup) continue;
await cleanup().catch(() => undefined);
}
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("round-trips localhost bridge requests over the sandbox queue without forwarding the bridge token", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-runtime-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge test\n", "utf8");
const runner = createExecRunner();
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
assets: [
{
key: "bridge",
localDir: bridgeAsset.localDir,
},
],
});
const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const bridgeToken = createSandboxCallbackBridgeToken();
const seenRequests: Array<{
method: string;
path: string;
query: string;
headers: Record<string, string>;
body: string;
}> = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
authorizeRequest: async (request) =>
request.path === "/api/agents/me" ? null : `Route not allowed: ${request.method} ${request.path}`,
handleRequest: async (request) => {
seenRequests.push({
method: request.method,
path: request.path,
query: request.query,
headers: request.headers,
body: request.body,
});
return {
status: 200,
headers: {
"content-type": "application/json",
etag: '"bridge-rev-1"',
"last-modified": "Tue, 01 Apr 2025 00:00:00 GMT",
},
body: JSON.stringify({
ok: true,
method: request.method,
path: request.path,
}),
};
},
});
cleanupFns.push(async () => {
await worker.stop();
});
const bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
});
cleanupFns.push(async () => {
await bridge.stop();
});
const okResponse = await fetch(`${bridge.baseUrl}/api/agents/me?view=compact`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
accept: "application/json",
"if-none-match": '"client-cache-key"',
"x-paperclip-run-id": "run-bridge-1",
"x-bridge-debug": "drop-me",
},
});
expect(okResponse.status).toBe(200);
expect(okResponse.headers.get("content-type")).toContain("application/json");
expect(okResponse.headers.get("etag")).toBe('"bridge-rev-1"');
expect(okResponse.headers.get("last-modified")).toBe("Tue, 01 Apr 2025 00:00:00 GMT");
await expect(okResponse.json()).resolves.toMatchObject({
ok: true,
method: "GET",
path: "/api/agents/me",
});
const deniedResponse = await fetch(`${bridge.baseUrl}/api/issues/issue-1`, {
method: "PATCH",
headers: {
authorization: `Bearer ${bridgeToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ status: "in_progress" }),
});
expect(deniedResponse.status).toBe(403);
await expect(deniedResponse.json()).resolves.toMatchObject({
error: "Route not allowed: PATCH /api/issues/issue-1",
});
const unauthorizedResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: "Bearer wrong-token",
},
});
expect(unauthorizedResponse.status).toBe(401);
await expect(unauthorizedResponse.json()).resolves.toMatchObject({
error: "Invalid bridge token.",
});
expect(seenRequests).toHaveLength(1);
expect(seenRequests[0]).toMatchObject({
method: "GET",
path: "/api/agents/me",
query: "?view=compact",
body: "",
headers: {
accept: "application/json",
"if-none-match": '"client-cache-key"',
},
});
expect(seenRequests[0]?.headers.authorization).toBeUndefined();
expect(seenRequests[0]?.headers["x-paperclip-run-id"]).toBeUndefined();
});
it("denies non-allowlisted requests by default", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-default-policy-"));
cleanupDirs.push(rootDir);
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
let handled = 0;
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
handleRequest: async () => {
handled += 1;
return {
status: 200,
body: "should not happen",
};
},
});
await writeFile(
path.posix.join(directories.requestsDir, "req-1.json"),
`${JSON.stringify({
id: "req-1",
method: "DELETE",
path: "/api/secrets",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await worker.stop({ drainTimeoutMs: 1_000 });
const response = JSON.parse(
await readFile(path.posix.join(directories.responsesDir, "req-1.json"), "utf8"),
) as { status: number; body: string };
expect(handled).toBe(0);
expect(response.status).toBe(403);
expect(JSON.parse(response.body)).toEqual({
error: "Route not allowed: DELETE /api/secrets",
});
});
it("drains already-queued requests on stop", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-drain-"));
cleanupDirs.push(rootDir);
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const processed: string[] = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
authorizeRequest: async () => null,
handleRequest: async (request) => {
processed.push(request.id);
await new Promise((resolve) => setTimeout(resolve, 25));
return {
status: 200,
body: request.id,
};
},
});
await writeFile(
path.posix.join(directories.requestsDir, "req-a.json"),
`${JSON.stringify({
id: "req-a",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await writeFile(
path.posix.join(directories.requestsDir, "req-b.json"),
`${JSON.stringify({
id: "req-b",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await worker.stop({ drainTimeoutMs: 1_000 });
expect(processed).toEqual(["req-a", "req-b"]);
await expect(readFile(path.posix.join(directories.responsesDir, "req-a.json"), "utf8")).resolves.toContain("\"req-a\"");
await expect(readFile(path.posix.join(directories.responsesDir, "req-b.json"), "utf8")).resolves.toContain("\"req-b\"");
});
it("writes fast 503 responses for queued requests that miss the drain deadline", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-drain-timeout-"));
cleanupDirs.push(rootDir);
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const processed: string[] = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
authorizeRequest: async () => null,
handleRequest: async (request) => {
processed.push(request.id);
await new Promise((resolve) => setTimeout(resolve, 100));
return {
status: 200,
body: request.id,
};
},
});
await writeFile(
path.posix.join(directories.requestsDir, "req-a.json"),
`${JSON.stringify({
id: "req-a",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await writeFile(
path.posix.join(directories.requestsDir, "req-b.json"),
`${JSON.stringify({
id: "req-b",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
for (let attempt = 0; attempt < 50 && processed.length === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await worker.stop({ drainTimeoutMs: 10 });
expect(processed).toEqual(["req-a"]);
await expect(readFile(path.posix.join(directories.responsesDir, "req-a.json"), "utf8")).resolves.toContain("\"req-a\"");
await expect(readFile(path.posix.join(directories.responsesDir, "req-b.json"), "utf8")).resolves.toContain(
"Bridge worker stopped before request could be handled.",
);
});
it("rejects non-JSON request bodies and full queues at the bridge server", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-server-guards-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge guard test\n", "utf8");
const runner = createExecRunner();
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "bridge", localDir: bridgeAsset.localDir }],
});
const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const bridgeToken = createSandboxCallbackBridgeToken();
const bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
maxQueueDepth: 1,
});
cleanupFns.push(async () => {
await bridge.stop();
});
await writeFile(
path.posix.join(directories.requestsDir, "existing.json"),
`${JSON.stringify({
id: "existing",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
const queueFullResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
expect(queueFullResponse.status).toBe(503);
await expect(queueFullResponse.json()).resolves.toEqual({
error: "Bridge request queue is full.",
});
await rm(path.posix.join(directories.requestsDir, "existing.json"), { force: true });
const nonJsonResponse = await fetch(`${bridge.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: {
authorization: `Bearer ${bridgeToken}`,
"content-type": "text/plain",
},
body: "not json",
});
expect(nonJsonResponse.status).toBe(415);
await expect(nonJsonResponse.json()).resolves.toEqual({
error: "Bridge only accepts JSON request bodies.",
});
});
it("returns a 502 when the host response times out", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-timeout-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge timeout test\n", "utf8");
const runner = createExecRunner();
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "bridge", localDir: bridgeAsset.localDir }],
});
const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge");
const bridgeToken = createSandboxCallbackBridgeToken();
const bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
pollIntervalMs: 10,
responseTimeoutMs: 75,
});
cleanupFns.push(async () => {
await bridge.stop();
});
const response = await fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Timed out waiting for host bridge response.",
});
});
it("returns a 502 for malformed host response files", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-malformed-response-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge malformed response test\n", "utf8");
const runner = createExecRunner();
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "bridge", localDir: bridgeAsset.localDir }],
});
const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const bridgeToken = createSandboxCallbackBridgeToken();
const bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
pollIntervalMs: 10,
responseTimeoutMs: 1_000,
});
cleanupFns.push(async () => {
await bridge.stop();
});
const responsePromise = fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
const requestFile = await waitForJsonFile(directories.requestsDir);
await writeFile(
path.posix.join(directories.responsesDir, requestFile),
'{"status":200,"headers":{"content-type":"application/json"},"body"',
"utf8",
);
const response = await responsePromise;
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringMatching(/JSON|Unexpected|Unterminated/i),
});
});
});
@@ -0,0 +1,822 @@
import { randomBytes, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
const DEFAULT_BRIDGE_POLL_INTERVAL_MS = 100;
const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000;
const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000;
const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64;
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs";
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES;
export interface SandboxCallbackBridgeRouteRule {
method: string;
path: RegExp;
}
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
{ method: "GET", path: /^\/api\/agents\/me$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
{ method: "PATCH", path: /^\/api\/issues\/[^/]+$/ },
] as const;
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
"accept",
"content-type",
"if-match",
"if-none-match",
] as const;
export interface SandboxCallbackBridgeRequest {
id: string;
method: string;
path: string;
query: string;
headers: Record<string, string>;
/**
* UTF-8 body contents. The bridge rejects non-JSON request bodies; binary
* payloads are intentionally out of scope for this queue protocol.
*/
body: string;
createdAt: string;
}
export interface SandboxCallbackBridgeResponse {
id: string;
status: number;
headers: Record<string, string>;
body: string;
completedAt: string;
}
export interface SandboxCallbackBridgeAsset {
localDir: string;
entrypoint: string;
cleanup(): Promise<void>;
}
export interface SandboxCallbackBridgeDirectories {
rootDir: string;
requestsDir: string;
responsesDir: string;
logsDir: string;
readyFile: string;
pidFile: string;
logFile: string;
}
export interface SandboxCallbackBridgeQueueClient {
makeDir(remotePath: string): Promise<void>;
listJsonFiles(remotePath: string): Promise<string[]>;
readTextFile(remotePath: string): Promise<string>;
writeTextFile(remotePath: string, body: string): Promise<void>;
rename(fromPath: string, toPath: string): Promise<void>;
remove(remotePath: string): Promise<void>;
}
export interface SandboxCallbackBridgeWorkerHandle {
stop(options?: { drainTimeoutMs?: number }): Promise<void>;
}
export interface StartedSandboxCallbackBridgeServer {
baseUrl: string;
host: string;
port: number;
pid: number;
directories: SandboxCallbackBridgeDirectories;
stop(): Promise<void>;
}
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function normalizeMethod(value: string | null | undefined): string {
return typeof value === "string" && value.trim().length > 0 ? value.trim().toUpperCase() : "GET";
}
function normalizeTimeoutMs(value: number | null | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
}
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
if (Buffer.isBuffer(bytes)) return bytes;
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
function buildRunnerFailureMessage(action: string, result: RunProcessResult): string {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
const detail = stderr || stdout;
if (result.timedOut) {
return `${action} timed out${detail ? `: ${detail}` : ""}`;
}
return `${action} failed with exit code ${result.exitCode ?? "null"}${detail ? `: ${detail}` : ""}`;
}
async function runShell(
runner: CommandManagedRuntimeRunner,
cwd: string,
script: string,
timeoutMs: number,
): Promise<RunProcessResult> {
return await runner.execute({
command: "sh",
args: ["-lc", script],
cwd,
timeoutMs,
});
}
function requireSuccessfulResult(action: string, result: RunProcessResult): RunProcessResult {
if (!result.timedOut && result.exitCode === 0) return result;
throw new Error(buildRunnerFailureMessage(action, result));
}
function base64Chunks(body: string): string[] {
const out: string[] = [];
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
out.push(body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE));
}
return out;
}
export function createSandboxCallbackBridgeToken(bytes = DEFAULT_BRIDGE_TOKEN_BYTES): string {
return randomBytes(bytes).toString("base64url");
}
export function authorizeSandboxCallbackBridgeRequestWithRoutes(
request: Pick<SandboxCallbackBridgeRequest, "method" | "path">,
routes: readonly SandboxCallbackBridgeRouteRule[] = DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
): string | null {
const method = normalizeMethod(request.method);
return routes.some((route) => route.method === method && route.path.test(request.path))
? null
: `Route not allowed: ${method} ${request.path}`;
}
export function sanitizeSandboxCallbackBridgeHeaders(
headers: Record<string, string>,
allowlist: readonly string[] = DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST,
): Record<string, string> {
const allowed = new Set(allowlist.map((header) => header.toLowerCase()));
return Object.fromEntries(
Object.entries(headers).filter(([key]) => allowed.has(key.toLowerCase())),
);
}
export function sandboxCallbackBridgeDirectories(rootDir: string): SandboxCallbackBridgeDirectories {
return {
rootDir,
requestsDir: path.posix.join(rootDir, "requests"),
responsesDir: path.posix.join(rootDir, "responses"),
logsDir: path.posix.join(rootDir, "logs"),
readyFile: path.posix.join(rootDir, "ready.json"),
pidFile: path.posix.join(rootDir, "server.pid"),
logFile: path.posix.join(rootDir, "logs", "bridge.log"),
};
}
export function buildSandboxCallbackBridgeEnv(input: {
queueDir: string;
bridgeToken: string;
host?: string;
port?: number | null;
pollIntervalMs?: number | null;
responseTimeoutMs?: number | null;
maxQueueDepth?: number | null;
maxBodyBytes?: number | null;
}): Record<string, string> {
return {
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
PAPERCLIP_BRIDGE_QUEUE_DIR: input.queueDir,
PAPERCLIP_BRIDGE_TOKEN: input.bridgeToken,
PAPERCLIP_BRIDGE_HOST: input.host?.trim() || "127.0.0.1",
PAPERCLIP_BRIDGE_PORT: String(input.port && input.port > 0 ? Math.trunc(input.port) : 0),
PAPERCLIP_BRIDGE_POLL_INTERVAL_MS: String(
normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS),
),
PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: String(
normalizeTimeoutMs(input.responseTimeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS),
),
PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH: String(
normalizeTimeoutMs(input.maxQueueDepth, DEFAULT_BRIDGE_MAX_QUEUE_DEPTH),
),
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(
normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES),
),
};
}
export async function createSandboxCallbackBridgeAsset(): Promise<SandboxCallbackBridgeAsset> {
const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-asset-"));
const entrypoint = path.join(localDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
await fs.writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8");
return {
localDir,
entrypoint,
cleanup: async () => {
await fs.rm(localDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
export function createFileSystemSandboxCallbackBridgeQueueClient(): SandboxCallbackBridgeQueueClient {
return {
makeDir: async (remotePath) => {
await fs.mkdir(remotePath, { recursive: true });
},
listJsonFiles: async (remotePath) => {
const entries = await fs.readdir(remotePath, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
},
readTextFile: async (remotePath) => await fs.readFile(remotePath, "utf8"),
writeTextFile: async (remotePath, body) => {
await fs.mkdir(path.posix.dirname(remotePath), { recursive: true });
await fs.writeFile(remotePath, body, "utf8");
},
rename: async (fromPath, toPath) => {
await fs.mkdir(path.posix.dirname(toPath), { recursive: true });
await fs.rename(fromPath, toPath);
},
remove: async (remotePath) => {
await fs.rm(remotePath, { recursive: true, force: true }).catch(() => undefined);
},
};
}
export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
timeoutMs?: number | null;
}): SandboxCallbackBridgeQueueClient {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const runChecked = async (action: string, script: string) =>
requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs));
return {
makeDir: async (remotePath) => {
await runChecked(`mkdir ${remotePath}`, `mkdir -p ${shellQuote(remotePath)}`);
},
listJsonFiles: async (remotePath) => {
const result = await runShell(
input.runner,
input.remoteCwd,
[
`if [ -d ${shellQuote(remotePath)} ]; then`,
` for file in ${shellQuote(remotePath)}/*.json; do`,
` [ -f "$file" ] || continue`,
" basename \"$file\"",
" done",
"fi",
].join("\n"),
timeoutMs,
);
requireSuccessfulResult(`list ${remotePath}`, result);
return result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.sort((left, right) => left.localeCompare(right));
},
readTextFile: async (remotePath) => {
const result = await runChecked(`read ${remotePath}`, `base64 < ${shellQuote(remotePath)}`);
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64").toString("utf8");
},
writeTextFile: async (remotePath, body) => {
const remoteDir = path.posix.dirname(remotePath);
const tempPath = `${remotePath}.paperclip-upload.b64`;
await runChecked(
`prepare upload ${remotePath}`,
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} && : > ${shellQuote(tempPath)}`,
);
const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
for (const chunk of base64Chunks(base64Body)) {
await runChecked(
`append upload chunk ${remotePath}`,
`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`,
);
}
await runChecked(
`finalize upload ${remotePath}`,
`base64 -d < ${shellQuote(tempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
);
},
rename: async (fromPath, toPath) => {
await runChecked(
`rename ${fromPath}`,
`mkdir -p ${shellQuote(path.posix.dirname(toPath))} && mv ${shellQuote(fromPath)} ${shellQuote(toPath)}`,
);
},
remove: async (remotePath) => {
await runChecked(`remove ${remotePath}`, `rm -rf ${shellQuote(remotePath)}`);
},
};
}
async function writeBridgeResponse(
client: SandboxCallbackBridgeQueueClient,
responsePath: string,
response: SandboxCallbackBridgeResponse,
) {
const tempPath = `${responsePath}.tmp`;
await client.writeTextFile(tempPath, `${JSON.stringify(response)}\n`);
await client.rename(tempPath, responsePath);
}
export async function startSandboxCallbackBridgeWorker(input: {
client: SandboxCallbackBridgeQueueClient;
queueDir: string;
pollIntervalMs?: number | null;
authorizeRequest?: (request: SandboxCallbackBridgeRequest) => string | null | Promise<string | null>;
handleRequest: (request: SandboxCallbackBridgeRequest) => Promise<{
status: number;
headers?: Record<string, string>;
body?: string;
}>;
maxBodyBytes?: number | null;
}): Promise<SandboxCallbackBridgeWorkerHandle> {
const pollIntervalMs = normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS);
const maxBodyBytes = normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES);
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
await input.client.makeDir(directories.rootDir);
await input.client.makeDir(directories.requestsDir);
await input.client.makeDir(directories.responsesDir);
await input.client.makeDir(directories.logsDir);
let stopping = false;
let inFlight = 0;
let settled = false;
let stopDeadline = Number.POSITIVE_INFINITY;
let settleResolve: (() => void) | null = null;
const settledPromise = new Promise<void>((resolve) => {
settleResolve = resolve;
});
const authorizeRequest = input.authorizeRequest ??
((request: SandboxCallbackBridgeRequest) => authorizeSandboxCallbackBridgeRequestWithRoutes(request));
const processRequestFile = async (fileName: string) => {
const requestPath = path.posix.join(directories.requestsDir, fileName);
const responsePath = path.posix.join(directories.responsesDir, fileName);
const raw = await input.client.readTextFile(requestPath);
let request: SandboxCallbackBridgeRequest;
try {
request = JSON.parse(raw) as SandboxCallbackBridgeRequest;
} catch {
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
await writeBridgeResponse(input.client, responsePath, {
id: requestId,
status: 400,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "Invalid bridge request payload." }),
completedAt: new Date().toISOString(),
});
await input.client.remove(requestPath);
return;
}
const denialReason = await authorizeRequest(request);
if (denialReason) {
await writeBridgeResponse(input.client, responsePath, {
id: request.id,
status: 403,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: denialReason }),
completedAt: new Date().toISOString(),
});
await input.client.remove(requestPath);
return;
}
try {
const result = await input.handleRequest(request);
const responseBody = result.body ?? "";
if (Buffer.byteLength(responseBody, "utf8") > maxBodyBytes) {
throw new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
}
await writeBridgeResponse(input.client, responsePath, {
id: request.id,
status: result.status,
headers: result.headers ?? {},
body: responseBody,
completedAt: new Date().toISOString(),
});
} catch (error) {
console.warn(
`[paperclip] sandbox callback bridge handler failed for ${request.id}: ${error instanceof Error ? error.message : String(error)}`,
);
await writeBridgeResponse(input.client, responsePath, {
id: request.id,
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}),
completedAt: new Date().toISOString(),
});
} finally {
await input.client.remove(requestPath);
}
};
const failPendingRequests = async (message: string) => {
const fileNames = await input.client.listJsonFiles(directories.requestsDir).catch(() => []);
for (const fileName of fileNames) {
const requestPath = path.posix.join(directories.requestsDir, fileName);
const responsePath = path.posix.join(directories.responsesDir, fileName);
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
try {
const raw = await input.client.readTextFile(requestPath);
const parsed = JSON.parse(raw) as Partial<SandboxCallbackBridgeRequest>;
await writeBridgeResponse(input.client, responsePath, {
id: typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : requestId,
status: 503,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: message }),
completedAt: new Date().toISOString(),
});
} catch (error) {
console.warn(
`[paperclip] sandbox callback bridge failed to abort pending request ${requestId}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await input.client.remove(requestPath).catch(() => undefined);
}
}
};
const loop = (async () => {
try {
while (true) {
const fileNames = await input.client.listJsonFiles(directories.requestsDir);
if (fileNames.length === 0) {
if (stopping) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
continue;
}
for (const fileName of fileNames) {
if (stopping && Date.now() >= stopDeadline) break;
inFlight += 1;
try {
await processRequestFile(fileName);
} finally {
inFlight -= 1;
}
}
if (stopping && Date.now() >= stopDeadline) {
break;
}
}
} finally {
settled = true;
if (settleResolve) {
settleResolve();
}
}
})();
void loop;
return {
stop: async (options = {}) => {
stopping = true;
const drainMs = normalizeTimeoutMs(options.drainTimeoutMs, DEFAULT_BRIDGE_STOP_TIMEOUT_MS);
stopDeadline = Date.now() + drainMs;
if (!settled) {
await Promise.race([
settledPromise,
new Promise<void>((resolve) => setTimeout(resolve, drainMs)),
]);
}
await failPendingRequests("Bridge worker stopped before request could be handled.");
},
};
}
export async function startSandboxCallbackBridgeServer(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
assetRemoteDir: string;
queueDir: string;
bridgeToken: string;
bridgeAsset?: SandboxCallbackBridgeAsset | null;
host?: string;
port?: number | null;
pollIntervalMs?: number | null;
responseTimeoutMs?: number | null;
timeoutMs?: number | null;
nodeCommand?: string;
maxQueueDepth?: number | null;
maxBodyBytes?: number | null;
}): Promise<StartedSandboxCallbackBridgeServer> {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
if (input.bridgeAsset) {
const assetClient = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: input.runner,
remoteCwd: input.remoteCwd,
timeoutMs,
});
await assetClient.makeDir(input.assetRemoteDir);
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
await assetClient.writeTextFile(remoteEntrypoint, entrypointSource);
}
const env = buildSandboxCallbackBridgeEnv({
queueDir: input.queueDir,
bridgeToken: input.bridgeToken,
host: input.host,
port: input.port,
pollIntervalMs: input.pollIntervalMs,
responseTimeoutMs: input.responseTimeoutMs,
maxQueueDepth: input.maxQueueDepth,
maxBodyBytes: input.maxBodyBytes,
});
const nodeCommand = input.nodeCommand?.trim() || "node";
const startResult = await input.runner.execute({
command: "sh",
args: [
"-lc",
[
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
`nohup env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` +
`${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` +
`>> ${shellQuote(directories.logFile)} 2>&1 < /dev/null &`,
"pid=$!",
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
"printf '{\"pid\":%s}\\n' \"$pid\"",
].join("\n"),
],
cwd: input.remoteCwd,
timeoutMs,
});
requireSuccessfulResult("start sandbox callback bridge", startResult);
const readyResult = await runShell(
input.runner,
input.remoteCwd,
[
"i=0",
`while [ \"$i\" -lt 200 ]; do`,
` if [ -s ${shellQuote(directories.readyFile)} ]; then`,
` cat ${shellQuote(directories.readyFile)}`,
" exit 0",
" fi",
` if [ -s ${shellQuote(directories.logFile)} ] && ! kill -0 \"$(cat ${shellQuote(directories.pidFile)} 2>/dev/null)\" 2>/dev/null; then`,
` cat ${shellQuote(directories.logFile)} >&2`,
" exit 1",
" fi",
" i=$((i + 1))",
" sleep 0.05",
"done",
`echo "Timed out waiting for bridge readiness." >&2`,
`if [ -s ${shellQuote(directories.logFile)} ]; then cat ${shellQuote(directories.logFile)} >&2; fi`,
"exit 1",
].join("\n"),
timeoutMs,
);
requireSuccessfulResult("wait for sandbox callback bridge readiness", readyResult);
let readyData: { host?: string; port?: number; baseUrl?: string; pid?: number };
try {
readyData = JSON.parse(readyResult.stdout.trim()) as { host?: string; port?: number; baseUrl?: string; pid?: number };
} catch (error) {
throw new Error(
`Sandbox callback bridge wrote invalid readiness JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
const host = typeof readyData.host === "string" && readyData.host.trim().length > 0
? readyData.host.trim()
: "127.0.0.1";
const port = typeof readyData.port === "number" && Number.isFinite(readyData.port) ? readyData.port : 0;
if (!port) {
throw new Error("Sandbox callback bridge did not report a listening port.");
}
const baseUrl =
typeof readyData.baseUrl === "string" && readyData.baseUrl.trim().length > 0
? readyData.baseUrl.trim()
: `http://${host}:${port}`;
return {
baseUrl,
host,
port,
pid: typeof readyData.pid === "number" && Number.isFinite(readyData.pid) ? readyData.pid : 0,
directories,
stop: async () => {
const stopResult = await input.runner.execute({
command: "sh",
args: [
"-lc",
[
`if [ -s ${shellQuote(directories.pidFile)} ]; then`,
` pid="$(cat ${shellQuote(directories.pidFile)})"`,
" kill \"$pid\" 2>/dev/null || true",
" i=0",
" while kill -0 \"$pid\" 2>/dev/null && [ \"$i\" -lt 40 ]; do",
" i=$((i + 1))",
" sleep 0.05",
" done",
"fi",
`rm -f ${shellQuote(directories.pidFile)} ${shellQuote(directories.readyFile)}`,
].join("\n"),
],
cwd: input.remoteCwd,
timeoutMs,
});
if (stopResult.timedOut) {
throw new Error(buildRunnerFailureMessage("stop sandbox callback bridge", stopResult));
}
},
};
}
function getSandboxCallbackBridgeServerSource(): string {
return `import { randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
const queueDir = process.env.PAPERCLIP_BRIDGE_QUEUE_DIR;
const bridgeToken = process.env.PAPERCLIP_BRIDGE_TOKEN;
const host = process.env.PAPERCLIP_BRIDGE_HOST || "127.0.0.1";
const port = Number(process.env.PAPERCLIP_BRIDGE_PORT || "0");
const pollIntervalMs = Number(process.env.PAPERCLIP_BRIDGE_POLL_INTERVAL_MS || "100");
const responseTimeoutMs = Number(process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS || "30000");
const maxQueueDepth = Number(process.env.PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}");
const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}");
const allowedHeaders = new Set(${JSON.stringify([...DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST])});
if (!queueDir || !bridgeToken) {
throw new Error("PAPERCLIP_BRIDGE_QUEUE_DIR and PAPERCLIP_BRIDGE_TOKEN are required.");
}
const requestsDir = path.posix.join(queueDir, "requests");
const responsesDir = path.posix.join(queueDir, "responses");
const logsDir = path.posix.join(queueDir, "logs");
const readyFile = path.posix.join(queueDir, "ready.json");
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeHeaders(headers) {
const out = {};
for (const [key, value] of Object.entries(headers)) {
if (value == null) continue;
const normalizedKey = key.toLowerCase();
if (!allowedHeaders.has(normalizedKey)) {
continue;
}
out[normalizedKey] = Array.isArray(value) ? value.join(", ") : String(value);
}
return out;
}
async function readBody(req) {
const chunks = [];
let totalBytes = 0;
for await (const chunk of req) {
const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(nextChunk);
totalBytes += nextChunk.byteLength;
if (totalBytes > maxBodyBytes) {
throw new Error("Bridge request body exceeded the configured size limit.");
}
}
return Buffer.concat(chunks).toString("utf8");
}
async function queueDepth() {
const entries = await fs.readdir(requestsDir, { withFileTypes: true }).catch(() => []);
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length;
}
function tokensMatch(received) {
const expected = Buffer.from(bridgeToken, "utf8");
const actual = Buffer.from(typeof received === "string" ? received : "", "utf8");
if (expected.length !== actual.length) return false;
return timingSafeEqual(expected, actual);
}
async function waitForResponse(requestId) {
const responsePath = path.posix.join(responsesDir, \`\${requestId}.json\`);
const deadline = Date.now() + responseTimeoutMs;
while (Date.now() < deadline) {
const body = await fs.readFile(responsePath, "utf8").catch(() => null);
if (body != null) {
await fs.rm(responsePath, { force: true }).catch(() => undefined);
return JSON.parse(body);
}
await sleep(pollIntervalMs);
}
throw new Error("Timed out waiting for host bridge response.");
}
const server = createServer(async (req, res) => {
try {
const auth = req.headers.authorization || "";
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
if (!tokensMatch(receivedToken)) {
res.statusCode = 401;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Invalid bridge token." }));
return;
}
if (await queueDepth() >= maxQueueDepth) {
res.statusCode = 503;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Bridge request queue is full." }));
return;
}
const url = new URL(req.url || "/", "http://127.0.0.1");
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
res.statusCode = 415;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Bridge only accepts JSON request bodies." }));
return;
}
const requestId = randomUUID();
const requestBody = await readBody(req);
const payload = {
id: requestId,
method: req.method || "GET",
path: url.pathname,
query: url.search,
headers: normalizeHeaders(req.headers),
body: requestBody,
createdAt: new Date().toISOString(),
};
const requestPath = path.posix.join(requestsDir, \`\${requestId}.json\`);
const tempPath = \`\${requestPath}.tmp\`;
await fs.writeFile(tempPath, \`\${JSON.stringify(payload)}\\n\`, "utf8");
await fs.rename(tempPath, requestPath);
const response = await waitForResponse(requestId);
res.statusCode = typeof response.status === "number" ? response.status : 200;
for (const [key, value] of Object.entries(response.headers || {})) {
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
res.setHeader(key, value);
}
res.end(typeof response.body === "string" ? response.body : "");
} catch (error) {
res.statusCode = 502;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
});
async function shutdown() {
server.close(() => {
process.exit(0);
});
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
await fs.mkdir(requestsDir, { recursive: true });
await fs.mkdir(responsesDir, { recursive: true });
await fs.mkdir(logsDir, { recursive: true });
server.listen(port, host, async () => {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Bridge server did not expose a TCP address.");
}
const ready = {
pid: process.pid,
host,
port: address.port,
baseUrl: \`http://\${host}:\${address.port}\`,
startedAt: new Date().toISOString(),
};
const tempReadyFile = \`\${readyFile}.tmp\`;
await fs.writeFile(tempReadyFile, JSON.stringify(ready), "utf8");
await fs.rename(tempReadyFile, readyFile);
});`;
}
@@ -1,4 +1,4 @@
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
@@ -73,6 +73,13 @@ describe("sandbox managed runtime", () => {
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async (remotePath) => {
const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
},
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
@@ -27,6 +27,7 @@ export interface SandboxManagedRuntimeClient {
makeDir(remotePath: string): Promise<void>;
writeFile(remotePath: string, bytes: ArrayBuffer): Promise<void>;
readFile(remotePath: string): Promise<Buffer | Uint8Array | ArrayBuffer>;
listFiles(remotePath: string): Promise<string[]>;
remove(remotePath: string): Promise<void>;
run(command: string, options: { timeoutMs: number }): Promise<void>;
}
@@ -1,9 +1,14 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
applyPaperclipWorkspaceEnv,
appendWithByteCap,
buildInvocationEnvForLogs,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
materializePaperclipSkillCopy,
renderPaperclipWakePrompt,
runningProcesses,
runChildProcess,
@@ -39,6 +44,82 @@ async function waitForTextMatch(read: () => string, pattern: RegExp, timeoutMs =
return read().match(pattern);
}
describe("buildInvocationEnvForLogs", () => {
it("redacts inline secrets from resolved command metadata", () => {
const loggedEnv = buildInvocationEnvForLogs(
{ SAFE_VALUE: "visible" },
{
resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret",
},
);
expect(loggedEnv.SAFE_VALUE).toBe("visible");
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(
"env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***",
);
});
});
describe("materializePaperclipSkillCopy", () => {
it("refuses to materialize into an ancestor of the source", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
try {
const source = path.join(root, "parent", "skill");
await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8");
await expect(materializePaperclipSkillCopy(source, path.join(root, "parent"))).rejects.toThrow(
/ancestor/,
);
await expect(fs.readFile(path.join(source, "SKILL.md"), "utf8")).resolves.toBe("# skill\n");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not delete and recopy an unchanged materialized skill target", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
try {
const source = path.join(root, "source");
const target = path.join(root, "target");
await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8");
const first = await materializePaperclipSkillCopy(source, target);
expect(first.copiedFiles).toBe(1);
await fs.writeFile(path.join(target, "local-marker.txt"), "keep\n", "utf8");
const second = await materializePaperclipSkillCopy(source, target);
expect(second.copiedFiles).toBe(0);
await expect(fs.readFile(path.join(target, "local-marker.txt"), "utf8")).resolves.toBe("keep\n");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("breaks stale materialization locks left by dead processes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
try {
const source = path.join(root, "source");
const target = path.join(root, "target");
const lock = `${target}.lock`;
await fs.mkdir(source, { recursive: true });
await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8");
await fs.mkdir(lock, { recursive: true });
await fs.writeFile(
path.join(lock, "owner.json"),
JSON.stringify({ pid: 999_999_999, createdAt: "2000-01-01T00:00:00.000Z" }),
"utf8",
);
await expect(materializePaperclipSkillCopy(source, target)).resolves.toMatchObject({ copiedFiles: 1 });
await expect(fs.readFile(path.join(target, "SKILL.md"), "utf8")).resolves.toBe("# skill\n");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe("runChildProcess", () => {
it("does not arm a timeout when timeoutSec is 0", async () => {
const result = await runChildProcess(
+201 -2
View File
@@ -1,7 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
import { redactCommandText } from "./command-redaction.js";
import type {
AdapterSkillEntry,
AdapterSkillSnapshot,
@@ -76,10 +78,14 @@ export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024;
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
const REDACTED_LOG_VALUE = "***REDACTED***";
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
"../../skills",
"../../../../../skills",
];
const MATERIALIZED_SKILL_SENTINEL = ".paperclip-materialized-skill.json";
const MATERIALIZED_SKILL_LOCK_OWNER = "owner.json";
const MATERIALIZED_SKILL_LOCK_STALE_MS = 30_000;
export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
@@ -111,6 +117,11 @@ export interface InstalledSkillTarget {
kind: "symlink" | "directory" | "file";
}
export interface MaterializedPaperclipSkillCopyResult {
copiedFiles: number;
skippedSymlinks: string[];
}
interface PersistentSkillSnapshotOptions {
adapterType: string;
availableEntries: PaperclipSkillEntry[];
@@ -780,11 +791,15 @@ export function renderPaperclipWakePrompt(
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? REDACTED_LOG_VALUE : value;
}
return redacted;
}
export function redactCommandTextForLogs(command: string): string {
return redactCommandText(command, REDACTED_LOG_VALUE);
}
export function buildInvocationEnvForLogs(
env: Record<string, string>,
options: {
@@ -806,7 +821,7 @@ export function buildInvocationEnvForLogs(
const resolvedCommand = options.resolvedCommand?.trim();
if (resolvedCommand) {
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand;
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = redactCommandTextForLogs(resolvedCommand);
}
return redactEnvForLogs(merged);
@@ -1395,6 +1410,190 @@ export async function ensurePaperclipSkillSymlink(
return "repaired";
}
async function hashSkillDirectory(root: string): Promise<string> {
const hash = createHash("sha256");
async function visit(candidate: string, relativePath: string): Promise<void> {
const stat = await fs.lstat(candidate);
if (stat.isSymbolicLink()) {
hash.update(`symlink:${relativePath}\n`);
return;
}
if (stat.isDirectory()) {
hash.update(`dir:${relativePath}\n`);
const entries = await fs.readdir(candidate, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
await visit(path.join(candidate, entry.name), childRelativePath);
}
return;
}
if (stat.isFile()) {
hash.update(`file:${relativePath}:${stat.mode}\n`);
hash.update(await fs.readFile(candidate));
hash.update("\n");
return;
}
hash.update(`other:${relativePath}:${stat.mode}\n`);
}
await visit(root, "");
return hash.digest("hex");
}
async function materializedSkillFingerprintMatches(targetRoot: string, sourceFingerprint: string): Promise<boolean> {
try {
const raw = JSON.parse(await fs.readFile(path.join(targetRoot, MATERIALIZED_SKILL_SENTINEL), "utf8")) as unknown;
const parsed = parseObject(raw);
return parsed.version === 1 && parsed.sourceFingerprint === sourceFingerprint;
} catch {
return false;
}
}
async function acquireMaterializeLock(lockDir: string): Promise<() => Promise<void>> {
await fs.mkdir(path.dirname(lockDir), { recursive: true });
const deadline = Date.now() + MATERIALIZED_SKILL_LOCK_STALE_MS;
while (true) {
try {
await fs.mkdir(lockDir);
await fs.writeFile(
path.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER),
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
"utf8",
);
return async () => {
await fs.rm(lockDir, { recursive: true, force: true });
};
} catch (err) {
const code = err && typeof err === "object" ? (err as { code?: unknown }).code : null;
if (code !== "EEXIST") throw err;
if (await removeStaleMaterializeLock(lockDir, MATERIALIZED_SKILL_LOCK_STALE_MS)) continue;
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for Paperclip skill materialization lock at ${lockDir}`);
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
function isPidAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
const code = err && typeof err === "object" ? (err as { code?: unknown }).code : null;
return code === "EPERM";
}
}
async function removeStaleMaterializeLock(lockDir: string, staleMs: number): Promise<boolean> {
const ownerPath = path.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER);
let shouldRemove = false;
try {
const raw = JSON.parse(await fs.readFile(ownerPath, "utf8")) as unknown;
const owner = parseObject(raw);
const pid = typeof owner.pid === "number" ? owner.pid : 0;
const createdAt = typeof owner.createdAt === "string" ? Date.parse(owner.createdAt) : Number.NaN;
const ageMs = Number.isFinite(createdAt) ? Date.now() - createdAt : staleMs + 1;
shouldRemove = !isPidAlive(pid) || ageMs > staleMs;
} catch {
const stat = await fs.stat(lockDir).catch(() => null);
shouldRemove = !stat || Date.now() - stat.mtimeMs > staleMs;
}
if (!shouldRemove) return false;
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {});
return true;
}
export async function materializePaperclipSkillCopy(
source: string,
target: string,
): Promise<MaterializedPaperclipSkillCopyResult> {
const sourceRoot = path.resolve(source);
const targetRoot = path.resolve(target);
const relativeTarget = path.relative(sourceRoot, targetRoot);
const relativeSource = path.relative(targetRoot, sourceRoot);
if (
!relativeTarget ||
(!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) ||
!relativeSource ||
(!relativeSource.startsWith("..") && !path.isAbsolute(relativeSource))
) {
throw new Error("Refusing to materialize a skill into itself, an ancestor, or one of its descendants.");
}
const rootStat = await fs.lstat(sourceRoot);
if (rootStat.isSymbolicLink()) {
throw new Error("Refusing to materialize a skill root that is itself a symlink.");
}
if (!rootStat.isDirectory()) {
throw new Error("Paperclip skills must be directories.");
}
const result: MaterializedPaperclipSkillCopyResult = {
copiedFiles: 0,
skippedSymlinks: [],
};
const lockDir = `${targetRoot}.lock`;
const releaseLock = await acquireMaterializeLock(lockDir);
const tempRoot = `${targetRoot}.tmp-${process.pid}-${randomUUID()}`;
async function copyEntry(sourcePath: string, targetPath: string, relativePath: string): Promise<void> {
const stat = await fs.lstat(sourcePath);
if (stat.isSymbolicLink()) {
result.skippedSymlinks.push(relativePath || ".");
return;
}
if (stat.isDirectory()) {
await fs.mkdir(targetPath, { recursive: true });
const entries = await fs.readdir(sourcePath, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
await copyEntry(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), childRelativePath);
}
return;
}
if (stat.isFile()) {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
await fs.copyFile(sourcePath, targetPath);
});
await fs.chmod(targetPath, stat.mode).catch(() => {});
result.copiedFiles += 1;
}
}
try {
const sourceFingerprint = await hashSkillDirectory(sourceRoot);
if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result;
await copyEntry(sourceRoot, tempRoot, "");
await fs.writeFile(
path.join(tempRoot, MATERIALIZED_SKILL_SENTINEL),
`${JSON.stringify({
version: 1,
sourceFingerprint,
copiedFiles: result.copiedFiles,
skippedSymlinks: result.skippedSymlinks,
}, null, 2)}\n`,
"utf8",
);
if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result;
await fs.rm(targetRoot, { recursive: true, force: true });
await fs.rename(tempRoot, targetRoot);
return result;
} finally {
await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {});
await releaseLock();
}
}
export async function removeMaintainerOnlySkillSymlinks(
skillsHome: string,
allowedSkillNames: Iterable<string>,
@@ -37,6 +37,7 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
};
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
"acpx_local",
"claude_local",
"codex_local",
"cursor",
@@ -47,6 +48,11 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
]);
export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement> = {
acpx_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
},
claude_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
+2 -2
View File
@@ -476,8 +476,8 @@ async function importGitWorkspaceToSsh(input: {
`if [ ! -d ${shellQuote(path.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`,
`git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`,
input.snapshot.branchName
? `git -C ${shellQuote(input.remoteDir)} checkout -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null`
: `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
? `git -C ${shellQuote(input.remoteDir)} checkout --force -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null`
: `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
`git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
`git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`,
].join("\n");
+34
View File
@@ -144,6 +144,16 @@ export interface AdapterModel {
label: string;
}
export type AdapterModelProfileKey = "cheap";
export interface AdapterModelProfileDefinition {
key: AdapterModelProfileKey;
label: string;
description?: string;
adapterConfig: Record<string, unknown>;
source?: "adapter_default" | "discovered";
}
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
export interface AdapterEnvironmentCheck {
@@ -216,6 +226,20 @@ export interface AdapterEnvironmentTestContext {
companyId: string;
adapterType: string;
config: Record<string, unknown>;
/**
* Optional execution target the adapter should run probes against.
*
* If omitted (or `kind === "local"`), the adapter tests on the Paperclip
* host. For SSH/sandbox targets the adapter should run command/auth probes
* inside the remote environment so the result reflects what an agent run
* would actually see at execution time.
*/
executionTarget?: AdapterExecutionTarget | null;
/**
* Friendly name of the environment being tested (when `executionTarget` is set).
* Surfaced in check messages so users see which environment the probe ran in.
*/
environmentName?: string | null;
deployment?: {
mode?: "local_trusted" | "authenticated";
exposure?: "private" | "public";
@@ -315,6 +339,8 @@ export interface ServerAdapterModule {
supportsLocalAgentJwt?: boolean;
models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>;
modelProfiles?: AdapterModelProfileDefinition[];
listModelProfiles?: () => Promise<AdapterModelProfileDefinition[]>;
/**
* Optional explicit refresh hook for model discovery.
* Use this when the adapter caches discovered models and needs a bypass path
@@ -421,6 +447,14 @@ export interface CreateConfigValues {
promptTemplate: string;
model: string;
thinkingEffort: string;
/**
* Optional cheap model profile config for new agents on adapters that
* support model profiles. Persisted under
* `runtimeConfig.modelProfiles.cheap.adapterConfig`, never on the primary
* `adapterConfig`.
*/
cheapModel?: string;
cheapModelEnabled?: boolean;
chrome: boolean;
dangerouslySkipPermissions: boolean;
search: boolean;
+64
View File
@@ -0,0 +1,64 @@
{
"name": "@paperclipai/adapter-acpx-local",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/adapters/acpx-local"
},
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist",
"skills"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.31.4",
"@paperclipai/adapter-utils": "workspace:*",
"@zed-industries/codex-acp": "^0.12.0",
"acpx": "^0.6.1",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { printAcpxStreamEvent } from "./format-event.js";
function emit(payload: Record<string, unknown>): string {
return JSON.stringify(payload);
}
interface CapturedOutput {
log: string[];
stdout: string[];
}
function captureOutput(): { capture: CapturedOutput; restore: () => void } {
const log: string[] = [];
const stdout: string[] = [];
const logSpy = vi.spyOn(console, "log").mockImplementation((value?: unknown) => {
log.push(String(value ?? ""));
});
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
stdout.push(String(chunk ?? ""));
return true;
}) as typeof process.stdout.write);
return {
capture: { log, stdout },
restore: () => {
logSpy.mockRestore();
stdoutSpy.mockRestore();
},
};
}
function strip(value: string): string {
return value.replace(/\x1b\[[0-9;]*m/g, "");
}
describe("printAcpxStreamEvent", () => {
let captured: CapturedOutput;
let restore: () => void;
beforeEach(() => {
const result = captureOutput();
captured = result.capture;
restore = result.restore;
});
afterEach(() => {
restore();
});
it("renders acpx.session as a labeled session header", () => {
printAcpxStreamEvent(
emit({
type: "acpx.session",
agent: "claude",
acpSessionId: "acp-1",
mode: "persistent",
permissionMode: "approve-all",
}),
false,
);
expect(captured.log.map(strip)).toEqual(["claude session: acp-1 [persistent / approve-all]"]);
});
it("streams output text_delta to stdout for live progress", () => {
printAcpxStreamEvent(
emit({ type: "acpx.text_delta", text: "hello", channel: "output" }),
false,
);
expect(captured.log).toEqual([]);
expect(captured.stdout.map(strip)).toEqual(["hello"]);
});
it("renders thought text_delta on its own line", () => {
printAcpxStreamEvent(
emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }),
false,
);
expect(captured.log.map(strip)).toEqual(["thinking…"]);
});
it("renders tool_call with status and id", () => {
printAcpxStreamEvent(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-1",
status: "running",
text: "read README.md",
}),
false,
);
expect(captured.log.map(strip)).toEqual([
"tool_call: read [running] (tool-1)",
"read README.md",
]);
});
it("renders status events with optional context window", () => {
printAcpxStreamEvent(
emit({ type: "acpx.status", tag: "context_window", used: 100, size: 200000 }),
false,
);
expect(captured.log.map(strip)).toEqual(["status: context_window (100/200000 ctx)"]);
});
it("renders acpx.result and acpx.error", () => {
printAcpxStreamEvent(emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }), false);
printAcpxStreamEvent(emit({ type: "acpx.error", message: "auth required" }), false);
expect(captured.log.map(strip)).toEqual(["result: completed", "error: auth required"]);
});
it("falls back to plain output for non-JSON lines", () => {
printAcpxStreamEvent("not json", false);
expect(captured.log).toEqual(["not json"]);
});
it("still emits unknown / non-JSON lines when debug is enabled", () => {
printAcpxStreamEvent("not json", true);
expect(strip(captured.log[0])).toBe("not json");
});
});
@@ -0,0 +1,121 @@
import pc from "picocolors";
function parseJson(line: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(line);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringify(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function pickToolUseId(parsed: Record<string, unknown>): string {
return (
asString(parsed.toolCallId) ||
asString(parsed.toolUseId) ||
asString(parsed.id)
);
}
function statusLine(parsed: Record<string, unknown>): string {
const text = asString(parsed.text).trim();
const tag = asString(parsed.tag).trim();
const used = asNumber(parsed.used, -1);
const size = asNumber(parsed.size, -1);
const parts: string[] = [];
if (text) parts.push(text);
if (tag && !text) parts.push(tag);
if (used >= 0 && size > 0) parts.push(`(${used}/${size} ctx)`);
return parts.join(" ") || tag || "status";
}
export function printAcpxStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
const parsed = parseJson(line);
if (!parsed) {
if (debug) console.log(pc.gray(line));
else console.log(line);
return;
}
const type = asString(parsed.type);
if (type === "acpx.session") {
const agent = asString(parsed.agent, "acpx");
const session =
asString(parsed.acpSessionId) ||
asString(parsed.sessionId) ||
asString(parsed.runtimeSessionName);
const mode = asString(parsed.mode);
const permissionMode = asString(parsed.permissionMode);
const tail = [mode, permissionMode].filter(Boolean).join(" / ");
const suffix = tail ? ` [${tail}]` : "";
console.log(pc.blue(`${agent} session${session ? `: ${session}` : ""}${suffix}`));
return;
}
if (type === "acpx.text_delta") {
const text = asString(parsed.text);
if (!text) return;
const channel = asString(parsed.channel) || asString(parsed.stream);
const isThought = channel === "thought" || channel === "thinking";
if (isThought) console.log(pc.gray(text));
else process.stdout.write(pc.green(text));
return;
}
if (type === "acpx.tool_call") {
const name = asString(parsed.name, "acp_tool");
const status = asString(parsed.status);
const id = pickToolUseId(parsed);
const header = status ? `tool_call: ${name} [${status}]` : `tool_call: ${name}`;
const idSuffix = id ? ` (${id})` : "";
const isError = status === "failed" || status === "cancelled";
console.log((isError ? pc.red : pc.yellow)(`${header}${idSuffix}`));
if (parsed.input !== undefined) {
console.log(pc.gray(stringify(parsed.input)));
} else {
const text = asString(parsed.text).trim();
if (text) console.log(pc.gray(text));
}
return;
}
if (type === "acpx.tool_result") {
const isError = parsed.isError === true || parsed.error !== undefined;
console.log((isError ? pc.red : pc.cyan)(`tool_result: ${asString(parsed.name, "acp_tool")}`));
const content = stringify(parsed.content ?? parsed.output ?? parsed.error);
if (content) console.log((isError ? pc.red : pc.gray)(content));
return;
}
if (type === "acpx.status") {
console.log(pc.gray(`status: ${statusLine(parsed)}`));
return;
}
if (type === "acpx.result") {
const summary = asString(parsed.summary, asString(parsed.stopReason, asString(parsed.subtype, "complete")));
console.log(pc.blue(`result: ${summary}`));
return;
}
if (type === "acpx.error") {
console.log(pc.red(`error: ${asString(parsed.message, line)}`));
return;
}
console.log(debug ? pc.gray(line) : line);
}
@@ -0,0 +1 @@
export { printAcpxStreamEvent } from "./format-event.js";
+47
View File
@@ -0,0 +1,47 @@
export const type = "acpx_local";
export const label = "ACPX (local)";
export const DEFAULT_ACPX_LOCAL_AGENT = "claude";
export const DEFAULT_ACPX_LOCAL_MODE = "persistent";
export const DEFAULT_ACPX_LOCAL_PERMISSION_MODE = "approve-all";
export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny";
export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0;
export const acpxAgentOptions = [
{ id: "claude", label: "Claude via ACPX" },
{ id: "codex", label: "Codex via ACPX" },
{ id: "custom", label: "Custom ACP command" },
] as const;
export const agentConfigurationDoc = `# acpx_local agent configuration
Adapter: acpx_local
Use when:
- The agent should run through Agent Client Protocol via ACPX on the Paperclip host or a managed execution environment.
- You want one built-in adapter that can target Claude, Codex, or a custom ACP server command.
- You need Paperclip-managed session identity and live streamed ACP events in later ACPX runtime phases.
Don't use when:
- You need today's stable Claude Code or Codex CLI wrapper behavior. Use claude_local or codex_local until acpx_local runtime execution is enabled.
- The host cannot satisfy ACPX's Node >=22.12.0 prerequisite.
- The agent runtime is not an ACP server and cannot be launched through ACPX.
Core fields:
- agent (string, optional): claude, codex, or custom. Defaults to claude.
- agentCommand (string, optional): custom ACP command when agent=custom, or an override for a built-in ACP agent command.
- mode (string, optional): persistent or oneshot. Defaults to persistent.
- cwd (string, optional): default absolute working directory fallback for the agent process.
- permissionMode (string, optional): defaults to approve-all, meaning ACPX permission requests are auto-approved.
- nonInteractivePermissions (string, optional): fallback behavior when ACPX cannot ask interactively. Supported values are deny and fail.
- stateDir (string, optional): ACPX state directory. Defaults to a Paperclip-managed company/agent scoped location.
- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction.
- promptTemplate (string, optional): run prompt template.
- bootstrapPromptTemplate (string, optional): first-run bootstrap prompt template.
- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout.
- env (object, optional): KEY=VALUE environment variables or secret bindings.
Dependency decision:
- acpx_local declares direct dependencies on acpx, @agentclientprotocol/claude-agent-acp, and @zed-industries/codex-acp so the built-in adapter has deterministic package resolution instead of relying on globally installed ACP commands.
- ACPX currently requires Node >=22.12.0. Paperclip keeps the repo-wide Node >=20 engine and surfaces the stricter runtime prerequisite through acpx_local diagnostics.
`;
@@ -0,0 +1,102 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_MODE,
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
acpxAgentOptions,
} from "../index.js";
export function getConfigSchema(): AdapterConfigSchema {
return {
fields: [
{
key: "agent",
label: "ACP agent",
type: "select",
default: DEFAULT_ACPX_LOCAL_AGENT,
required: true,
options: acpxAgentOptions.map((agent) => ({ value: agent.id, label: agent.label })),
hint: "Choose the ACP agent launched through ACPX.",
},
{
key: "agentCommand",
label: "Agent command",
type: "text",
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
},
{
key: "mode",
label: "Session mode",
type: "select",
default: DEFAULT_ACPX_LOCAL_MODE,
options: [
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One shot" },
],
},
{
key: "permissionMode",
label: "Permission mode",
type: "select",
default: DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
options: [
{ value: "approve-all", label: "Approve all" },
{ value: "default", label: "Approve reads" },
],
hint: "Defaults to maximum permissions. Approve reads grants read-only requests and asks for approval on writes.",
},
{
key: "nonInteractivePermissions",
label: "Non-interactive permissions",
type: "select",
default: DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
options: [
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
},
{
key: "cwd",
label: "Working directory",
type: "text",
hint: "Absolute fallback directory. Paperclip execution workspaces can override this at runtime.",
},
{
key: "stateDir",
label: "State directory",
type: "text",
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
},
{
key: "instructionsFilePath",
label: "Instructions file",
type: "text",
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
},
{
key: "promptTemplate",
label: "Prompt template",
type: "textarea",
},
{
key: "bootstrapPromptTemplate",
label: "Bootstrap prompt template",
type: "textarea",
},
{
key: "timeoutSec",
label: "Timeout seconds",
type: "number",
default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
},
{
key: "env",
label: "Environment JSON",
type: "textarea",
hint: "Optional JSON object of environment values or secret bindings.",
},
],
};
}
@@ -0,0 +1,367 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createAcpxLocalExecutor } from "./execute.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
tempRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
async function onlyChildDir(parent: string): Promise<string> {
const entries = await fs.readdir(parent);
expect(entries).toHaveLength(1);
return path.join(parent, entries[0]!);
}
async function createSkill(root: string, name: string, body = `---\nrequired: false\n---\n# ${name}\n`) {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), body, "utf8");
return {
key: `paperclipai/test/${name}`,
runtimeName: name,
source: skillDir,
required: false,
};
}
function buildRuntime() {
return {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
close: async () => {},
};
}
async function runExecutor(config: Record<string, unknown>) {
const runtimeOptions: Record<string, unknown>[] = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
return buildRuntime() as never;
},
});
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config,
context: {},
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async (payload: unknown) => {
meta.push(payload as Record<string, unknown>);
},
} as never);
expect(result.exitCode).toBe(0);
return { logs, meta, runtimeOptions, result };
}
describe("acpx_local runtime skill isolation", () => {
it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => {
const root = await makeTempRoot();
const skillRoot = path.join(root, "skills");
const outsideRoot = path.join(root, "outside");
await fs.mkdir(outsideRoot, { recursive: true });
await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8");
const skill = await createSkill(skillRoot, "danger");
await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(skill.source, "leak.txt"));
await fs.symlink(outsideRoot, path.join(skill.source, "leak-dir"));
const stateDir = path.join(root, "state");
const { meta } = await runExecutor({
agent: "claude",
stateDir,
paperclipRuntimeSkills: [skill],
paperclipSkillSync: { desiredSkills: [skill.key] },
});
const mountedRoot = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude"));
const skillsHome = path.join(mountedRoot, ".claude", "skills");
const materializedSkill = path.join(skillsHome, skill.runtimeName);
expect(await fs.readFile(path.join(materializedSkill, "SKILL.md"), "utf8")).toContain("# danger");
expect(await pathExists(path.join(materializedSkill, "leak.txt"))).toBe(false);
expect(await pathExists(path.join(materializedSkill, "leak-dir"))).toBe(false);
expect(String(meta[0]?.prompt ?? "")).toContain(`Skill root: ${skillsHome}`);
});
it.skipIf(process.platform === "win32")("revokes removed ACPX Codex skills and skips symlinked descendants", async () => {
const root = await makeTempRoot();
const skillRoot = path.join(root, "skills");
const outsideRoot = path.join(root, "outside");
const codexHome = path.join(root, "codex-home");
await fs.mkdir(outsideRoot, { recursive: true });
await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8");
const keep = await createSkill(skillRoot, "keep");
const remove = await createSkill(skillRoot, "remove");
await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(keep.source, "leak.txt"));
await fs.symlink(outsideRoot, path.join(keep.source, "leak-dir"));
const baseConfig = {
agent: "codex",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: codexHome },
paperclipRuntimeSkills: [keep, remove],
};
await runExecutor({
...baseConfig,
paperclipSkillSync: { desiredSkills: [keep.key, remove.key] },
});
expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName, "SKILL.md"))).toBe(true);
await runExecutor({
...baseConfig,
paperclipSkillSync: { desiredSkills: [keep.key] },
});
expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "SKILL.md"))).toBe(true);
expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak.txt"))).toBe(false);
expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak-dir"))).toBe(false);
expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName))).toBe(false);
});
it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => {
const root = await makeTempRoot();
const skillRoot = path.join(root, "skills");
const codexHome = path.join(root, "codex-home");
const legacy = await createSkill(skillRoot, "legacy");
const skillsHome = path.join(codexHome, "skills");
await fs.mkdir(skillsHome, { recursive: true });
await fs.symlink(legacy.source, path.join(skillsHome, legacy.runtimeName));
await runExecutor({
agent: "codex",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: codexHome },
paperclipRuntimeSkills: [legacy],
paperclipSkillSync: { desiredSkills: [] },
});
expect(await pathExists(path.join(skillsHome, legacy.runtimeName))).toBe(false);
});
it.skipIf(process.platform === "win32")("replaces stale managed Codex auth files with source symlinks", async () => {
const root = await makeTempRoot();
const sourceCodexHome = path.join(root, "source-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const paperclipInstanceId = "test-instance";
const managedCodexHome = path.join(
paperclipHome,
"instances",
paperclipInstanceId,
"companies",
"company-1",
"codex-home",
);
await fs.mkdir(sourceCodexHome, { recursive: true });
await fs.mkdir(managedCodexHome, { recursive: true });
const sourceAuth = path.join(sourceCodexHome, "auth.json");
const managedAuth = path.join(managedCodexHome, "auth.json");
await fs.writeFile(sourceAuth, "{\"source\":true}", "utf8");
await fs.writeFile(managedAuth, "{\"stale\":true}", "utf8");
const previousCodexHome = process.env.CODEX_HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
try {
process.env.CODEX_HOME = sourceCodexHome;
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = paperclipInstanceId;
await runExecutor({
agent: "codex",
stateDir: path.join(root, "state"),
paperclipRuntimeSkills: [],
paperclipSkillSync: { desiredSkills: [] },
});
} finally {
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
}
const authStat = await fs.lstat(managedAuth);
expect(authStat.isSymbolicLink()).toBe(true);
expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth);
});
it("keeps fresh credential wrapper scripts across ACPX agent changes", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const baseConfig = {
agentCommand: "node ./fake-acp.js",
stateDir,
};
await runExecutor({
...baseConfig,
agent: "custom-a",
env: { PAPERCLIP_API_KEY: "old-key" },
});
await runExecutor({
...baseConfig,
agent: "custom-b",
env: { PAPERCLIP_API_KEY: "new-key" },
});
const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(2);
expect(wrappers.filter((name) => name.endsWith(".env"))).toHaveLength(2);
expect(wrappers.some((name) => name.startsWith("custom-a-"))).toBe(true);
expect(wrappers.some((name) => name.startsWith("custom-b-"))).toBe(true);
const wrapperPath = path.join(stateDir, "wrappers", wrappers.find((name) => name.startsWith("custom-b-") && name.endsWith(".sh"))!);
const envPath = path.join(stateDir, "wrappers", wrappers.find((name) => name.startsWith("custom-b-") && name.endsWith(".env"))!);
const wrapper = await fs.readFile(wrapperPath, "utf8");
const env = await fs.readFile(envPath, "utf8");
expect((await fs.stat(envPath)).mode & 0o777).toBe(0o600);
expect((await fs.stat(wrapperPath)).mode & 0o777).toBe(0o700);
expect(wrapper).toContain("node ./fake-acp.js");
expect(wrapper).not.toContain("PAPERCLIP_API_KEY");
expect(wrapper).not.toContain("new-key");
expect(wrapper).not.toContain("old-key");
expect(env).toContain("PAPERCLIP_API_KEY='new-key'");
expect(env).not.toContain("old-key");
});
it("cleans aged credential wrapper scripts across ACPX agent changes", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const wrappersDir = path.join(stateDir, "wrappers");
const baseConfig = {
agentCommand: "node ./fake-acp.js",
stateDir,
};
await runExecutor({
...baseConfig,
agent: "custom-a",
env: { PAPERCLIP_API_KEY: "old-key" },
});
const oldDate = new Date(Date.now() - 16 * 60 * 1000);
await Promise.all(
(await fs.readdir(wrappersDir))
.filter((name) => name.startsWith("custom-a-"))
.map((name) => fs.utimes(path.join(wrappersDir, name), oldDate, oldDate)),
);
await runExecutor({
...baseConfig,
agent: "custom-b",
env: { PAPERCLIP_API_KEY: "new-key" },
});
const wrappers = await fs.readdir(wrappersDir);
expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(1);
expect(wrappers.filter((name) => name.endsWith(".env"))).toHaveLength(1);
expect(wrappers.some((name) => name.startsWith("custom-a-"))).toBe(false);
expect(wrappers.some((name) => name.startsWith("custom-b-"))).toBe(true);
});
it("keeps distinct wrapper env files for concurrent runs with different credentials", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const baseConfig = {
agent: "custom-a",
agentCommand: "node ./fake-acp.js",
stateDir,
};
await runExecutor({
...baseConfig,
env: { PAPERCLIP_API_KEY: "first-key" },
});
await runExecutor({
...baseConfig,
env: { PAPERCLIP_API_KEY: "second-key" },
});
const envFileNames = (await fs.readdir(path.join(stateDir, "wrappers"))).filter((name) => name.endsWith(".env"));
expect(envFileNames).toHaveLength(2);
const envFiles = await Promise.all(
envFileNames.map(async (name) => fs.readFile(path.join(stateDir, "wrappers", name), "utf8")),
);
expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='first-key'"))).toHaveLength(1);
expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1);
});
it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => {
let observedApiKeyDuringStream: string | undefined;
const execute = createAcpxLocalExecutor({
createRuntime: () => ({
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
await Promise.resolve();
observedApiKeyDuringStream = process.env.PAPERCLIP_API_KEY;
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
close: async () => {},
}) as never,
});
const previousApiKey = process.env.PAPERCLIP_API_KEY;
try {
delete process.env.PAPERCLIP_API_KEY;
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js" },
context: {},
authToken: "runtime-key",
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
expect(observedApiKeyDuringStream).toBeUndefined();
} finally {
if (previousApiKey === undefined) delete process.env.PAPERCLIP_API_KEY;
else process.env.PAPERCLIP_API_KEY = previousApiKey;
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
export { execute, createAcpxLocalExecutor } from "./execute.js";
export { testEnvironment } from "./test.js";
export { getConfigSchema } from "./config-schema.js";
export { sessionCodec } from "./session-codec.js";
export { listAcpxSkills, syncAcpxSkills } from "./skills.js";
@@ -0,0 +1,50 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? { ...(value as Record<string, unknown>) } : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const runtimeSessionName = readString(record.runtimeSessionName);
const acpSessionId = readString(record.acpSessionId);
const agentSessionId = readString(record.agentSessionId);
const remoteExecution = readRecord(record.remoteExecution);
if (!runtimeSessionName && !acpSessionId && !agentSessionId) return null;
return {
...(runtimeSessionName ? { runtimeSessionName } : {}),
...(readString(record.sessionKey) ? { sessionKey: readString(record.sessionKey) } : {}),
...(readString(record.acpxRecordId) ? { acpxRecordId: readString(record.acpxRecordId) } : {}),
...(acpSessionId ? { acpSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
...(readString(record.agent) ? { agent: readString(record.agent) } : {}),
...(readString(record.cwd) ? { cwd: readString(record.cwd) } : {}),
...(readString(record.mode) ? { mode: readString(record.mode) } : {}),
...(readString(record.stateDir) ? { stateDir: readString(record.stateDir) } : {}),
...(readString(record.configFingerprint) ? { configFingerprint: readString(record.configFingerprint) } : {}),
...(readString(record.workspaceId) ? { workspaceId: readString(record.workspaceId) } : {}),
...(readString(record.repoUrl) ? { repoUrl: readString(record.repoUrl) } : {}),
...(readString(record.repoRef) ? { repoRef: readString(record.repoRef) } : {}),
...(remoteExecution ? { remoteExecution } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
return this.deserialize(params);
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readString(params.runtimeSessionName) ??
readString(params.acpSessionId) ??
readString(params.agentSessionId)
);
},
};
@@ -0,0 +1,106 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
type AcpxSkillAgent = "claude" | "codex" | "custom";
function normalizeAcpxSkillAgent(config: Record<string, unknown>): AcpxSkillAgent {
const configured = typeof config.agent === "string" ? config.agent.trim() : "";
if (configured === "codex" || configured === "custom") return configured;
if (configured === "claude" || configured === "") return "claude";
return "claude";
}
function configuredDetail(agent: AcpxSkillAgent): string {
if (agent === "codex") {
return "Will be linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session.";
}
return "Will be mounted into the next ACPX Claude session.";
}
function unsupportedDetail(): string {
return "Desired state is stored in Paperclip only; custom ACP commands need an explicit skill integration contract before runtime sync is available.";
}
async function buildAcpxSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const acpxAgent = normalizeAcpxSkillAgent(config);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const supported = acpxAgent !== "custom";
const warnings: string[] = supported
? []
: [
"Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.",
];
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",
supported,
mode: supported ? "ephemeral" : "unsupported",
desiredSkills,
entries,
warnings,
};
}
export async function listAcpxSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
return buildAcpxSkillSnapshot(ctx.config);
}
export async function syncAcpxSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildAcpxSkillSnapshot(ctx.config);
}
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it } from "vitest";
import { testEnvironment } from "./test.js";
const originalNodeVersion = process.version;
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
configurable: true,
enumerable: true,
value: version,
});
}
afterEach(() => {
setNodeVersion(originalNodeVersion);
});
describe("acpx_local environment diagnostics", () => {
it("does not force healthy default Claude diagnostics to warn", async () => {
setNodeVersion("v22.12.0");
const result = await testEnvironment({
adapterType: "acpx_local",
companyId: "test-company",
config: { agent: "claude" },
});
expect(result.status).toBe("pass");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "acpx_agent_selected",
level: "info",
message: "ACP agent selected: claude",
}),
);
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "acpx_runtime_scaffold",
level: "info",
}),
);
expect(result.checks).not.toContainEqual(
expect.objectContaining({
code: "acpx_runtime_scaffold",
level: "warn",
}),
);
});
});
@@ -0,0 +1,295 @@
import { createRequire } from "node:module";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asString,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
const require = createRequire(import.meta.url);
const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 12;
const MIN_NODE_PATCH = 0;
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function nodeVersionMeetsMinimum(version: string): boolean {
const [major = 0, minor = 0, patch = 0] = version
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10));
if (major > MIN_NODE_MAJOR) return true;
if (major < MIN_NODE_MAJOR) return false;
if (minor > MIN_NODE_MINOR) return true;
if (minor < MIN_NODE_MINOR) return false;
return patch >= MIN_NODE_PATCH;
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function getStringEnv(configEnv: Record<string, string>, key: string): string | undefined {
const configured = configEnv[key];
if (typeof configured === "string") return configured;
return process.env[key];
}
function credentialSource(configEnv: Record<string, string>, key: string): string {
return typeof configEnv[key] === "string" ? "adapter config env" : "server environment";
}
async function readJsonObject(filePath: string): Promise<Record<string, unknown> | null> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function readNestedString(record: Record<string, unknown>, pathSegments: string[]): string | null {
let current: unknown = record;
for (const segment of pathSegments) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[segment];
}
return isNonEmpty(current) ? current.trim() : null;
}
async function hasClaudeSubscriptionCredentials(configDir: string): Promise<boolean> {
for (const filename of [".credentials.json", "credentials.json"]) {
const credentials = await readJsonObject(path.join(configDir, filename));
if (!credentials) continue;
if (readNestedString(credentials, ["claudeAiOauth", "accessToken"])) return true;
}
return false;
}
async function hasCodexNativeCredentials(codexHome: string): Promise<boolean> {
const auth = await readJsonObject(path.join(codexHome, "auth.json"));
if (!auth) return false;
return Boolean(
readNestedString(auth, ["accessToken"]) ||
readNestedString(auth, ["tokens", "access_token"]) ||
readNestedString(auth, ["OPENAI_API_KEY"]),
);
}
async function buildCredentialHintChecks(
agent: string,
configEnv: Record<string, string>,
): Promise<AdapterEnvironmentCheck[]> {
if (agent === "claude") {
const bedrockFlag = getStringEnv(configEnv, "CLAUDE_CODE_USE_BEDROCK");
const bedrockBaseUrl = getStringEnv(configEnv, "ANTHROPIC_BEDROCK_BASE_URL");
const hasBedrock =
bedrockFlag === "1" ||
/^true$/i.test(bedrockFlag ?? "") ||
isNonEmpty(bedrockBaseUrl);
const bedrockSourceKey = isNonEmpty(bedrockFlag)
? "CLAUDE_CODE_USE_BEDROCK"
: "ANTHROPIC_BEDROCK_BASE_URL";
const anthropicApiKey = getStringEnv(configEnv, "ANTHROPIC_API_KEY");
const claudeConfigDir = isNonEmpty(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR"))
? path.resolve(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR") as string)
: path.join(os.homedir(), ".claude");
if (hasBedrock) {
return [{
code: "acpx_claude_bedrock_auth_detected",
level: "info",
message: "Claude credential hint: Bedrock auth indicators are configured.",
detail: `Detected in ${credentialSource(configEnv, bedrockSourceKey)}.`,
hint: "Ensure AWS credentials and AWS_REGION are available to the ACPX-launched Claude agent.",
}];
}
if (isNonEmpty(anthropicApiKey)) {
return [{
code: "acpx_claude_anthropic_api_key_detected",
level: "info",
message: "Claude credential hint: ANTHROPIC_API_KEY is set.",
detail: `Detected in ${credentialSource(configEnv, "ANTHROPIC_API_KEY")}.`,
}];
}
if (await hasClaudeSubscriptionCredentials(claudeConfigDir)) {
return [{
code: "acpx_claude_subscription_auth_detected",
level: "info",
message: "Claude credential hint: local Claude subscription credentials were found.",
detail: `Credentials found in ${claudeConfigDir}.`,
}];
}
return [{
code: "acpx_claude_credentials_missing",
level: "info",
message: "Claude credential hint: no Claude API, Bedrock, or local subscription credentials were detected.",
hint: "Set ANTHROPIC_API_KEY, configure Bedrock, or run `claude login` before starting an ACPX Claude agent.",
}];
}
if (agent === "codex") {
const openAiApiKey = getStringEnv(configEnv, "OPENAI_API_KEY");
const codexHome = isNonEmpty(getStringEnv(configEnv, "CODEX_HOME"))
? path.resolve(getStringEnv(configEnv, "CODEX_HOME") as string)
: path.join(os.homedir(), ".codex");
if (isNonEmpty(openAiApiKey)) {
return [{
code: "acpx_codex_openai_api_key_detected",
level: "info",
message: "Codex credential hint: OPENAI_API_KEY is set.",
detail: `Detected in ${credentialSource(configEnv, "OPENAI_API_KEY")}.`,
}];
}
if (await hasCodexNativeCredentials(codexHome)) {
return [{
code: "acpx_codex_native_auth_detected",
level: "info",
message: "Codex credential hint: local Codex auth configuration was found.",
detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`,
}];
}
return [{
code: "acpx_codex_credentials_missing",
level: "info",
message: "Codex credential hint: no OpenAI API key or local Codex auth configuration was detected.",
hint: "Set OPENAI_API_KEY or run `codex login` before starting an ACPX Codex agent.",
}];
}
return [];
}
function resolvePackage(name: string): AdapterEnvironmentCheck {
try {
const resolved = require.resolve(`${name}/package.json`);
return {
code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_present`,
level: "info",
message: `${name} is resolvable.`,
detail: resolved,
};
} catch {
return {
code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_missing`,
level: "error",
message: `${name} is not resolvable from the acpx_local adapter package.`,
hint: "Run pnpm install so the ACPX adapter dependencies are installed.",
};
}
}
async function checkDirectory(pathValue: string, code: string, label: string): Promise<AdapterEnvironmentCheck | null> {
const dir = pathValue.trim();
if (!dir) return null;
try {
await fs.mkdir(dir, { recursive: true });
await fs.access(dir);
return {
code,
level: "info",
message: `${label} is writable: ${dir}`,
};
} catch (err) {
return {
code: `${code}_invalid`,
level: "error",
message: err instanceof Error ? err.message : `${label} is not writable.`,
detail: dir,
};
}
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const config = parseObject(ctx.config);
const envConfig = parseObject(config.env);
const configEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") configEnv[key] = value;
}
const checks: AdapterEnvironmentCheck[] = [];
const nodeVersion = process.version;
checks.push({
code: nodeVersionMeetsMinimum(nodeVersion) ? "acpx_node_supported" : "acpx_node_unsupported",
level: nodeVersionMeetsMinimum(nodeVersion) ? "info" : "error",
message: nodeVersionMeetsMinimum(nodeVersion)
? `Node ${nodeVersion} satisfies ACPX's >=22.12.0 requirement.`
: `Node ${nodeVersion} does not satisfy ACPX's >=22.12.0 requirement.`,
hint: nodeVersionMeetsMinimum(nodeVersion)
? undefined
: "Run acpx_local agents with Node >=22.12.0 or use claude_local/codex_local on Node 20.",
});
checks.push(resolvePackage("acpx"));
checks.push(resolvePackage("@agentclientprotocol/claude-agent-acp"));
checks.push(resolvePackage("@zed-industries/codex-acp"));
const agent = asString(config.agent, "claude");
if (!["claude", "codex", "custom"].includes(agent)) {
checks.push({
code: "acpx_agent_invalid",
level: "error",
message: `Unsupported ACP agent: ${agent}`,
hint: "Use agent=claude, agent=codex, or agent=custom.",
});
} else {
checks.push({
code: "acpx_agent_selected",
level: "info",
message: `ACP agent selected: ${agent}`,
});
checks.push(...await buildCredentialHintChecks(agent, configEnv));
}
if (agent === "custom" && !asString(config.agentCommand, "")) {
checks.push({
code: "acpx_custom_command_missing",
level: "error",
message: "agentCommand is required when agent=custom.",
});
}
const stateDirCheck = await checkDirectory(asString(config.stateDir, ""), "acpx_state_dir_writable", "ACPX state directory");
if (stateDirCheck) checks.push(stateDirCheck);
const permissionMode = asString(config.permissionMode, "approve-all");
checks.push({
code: "acpx_permission_mode",
level: "info",
message: `Effective permission mode: ${permissionMode || "approve-all"}`,
});
checks.push({
code: "acpx_runtime_scaffold",
level: "info",
message: "acpx_local runtime execution is available through the bundled ACPX runtime.",
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
@@ -0,0 +1,139 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_MODE,
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
} from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function readNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const schemaValues = v.adapterSchemaValues ?? {};
const ac: Record<string, unknown> = {
agent: schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT,
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
nonInteractivePermissions:
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
};
for (const key of [
"agentCommand",
"cwd",
"stateDir",
"instructionsFilePath",
"promptTemplate",
"bootstrapPromptTemplate",
]) {
const value = schemaValues[key];
if (typeof value === "string" && value.trim()) ac[key] = value.trim();
}
if (!ac.cwd && v.cwd) ac.cwd = v.cwd;
if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (typeof schemaValues.env === "string") {
const schemaEnv = parseJsonObject(schemaValues.env);
if (schemaEnv) Object.assign(env, schemaEnv);
} else if (typeof schemaValues.env === "object" && schemaValues.env !== null && !Array.isArray(schemaValues.env)) {
Object.assign(env, schemaValues.env as Record<string, unknown>);
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}
@@ -0,0 +1,2 @@
export { parseAcpxStdoutLine } from "./parse-stdout.js";
export { buildAcpxLocalConfig } from "./build-config.js";
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { parseAcpxStdoutLine } from "./parse-stdout.js";
const TS = "2026-04-30T00:00:00.000Z";
function emit(payload: Record<string, unknown>): string {
return JSON.stringify(payload);
}
describe("parseAcpxStdoutLine", () => {
it("renders an init entry from acpx.session", () => {
const entries = parseAcpxStdoutLine(
emit({
type: "acpx.session",
agent: "claude",
acpSessionId: "acp-1",
runtimeSessionName: "runtime-1",
mode: "persistent",
permissionMode: "approve-all",
}),
TS,
);
expect(entries).toEqual([
{
kind: "init",
ts: TS,
model: "claude (persistent / approve-all)",
sessionId: "acp-1",
},
]);
});
it("routes output text_delta to the assistant transcript", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.text_delta", text: "hello", channel: "output", tag: "agent_message_chunk" }),
TS,
);
expect(entries).toEqual([
{ kind: "assistant", ts: TS, text: "hello", delta: true },
]);
});
it("routes thought text_delta to the thinking transcript", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }),
TS,
);
expect(entries).toEqual([
{ kind: "thinking", ts: TS, text: "thinking…", delta: true },
]);
});
it("falls back to stream when channel is missing", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.text_delta", text: "thinking…", stream: "thought" }),
TS,
);
expect(entries[0]).toMatchObject({ kind: "thinking" });
});
it("renders status events as system text with optional ctx usage", () => {
expect(
parseAcpxStdoutLine(
emit({ type: "acpx.status", text: "thinking", tag: "agent_thought_chunk" }),
TS,
),
).toEqual([{ kind: "system", ts: TS, text: "thinking" }]);
expect(
parseAcpxStdoutLine(
emit({ type: "acpx.status", tag: "context_window", used: 12000, size: 200000 }),
TS,
),
).toEqual([{ kind: "system", ts: TS, text: "context_window (12000/200000 ctx)" }]);
});
it("emits a tool_call entry that preserves toolCallId, status, and input", () => {
const entries = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-1",
status: "running",
text: "read README.md",
}),
TS,
);
expect(entries).toEqual([
{
kind: "tool_call",
ts: TS,
name: "read",
toolUseId: "tool-1",
input: { text: "read README.md", status: "running" },
},
]);
});
it("emits a paired tool_result entry when a tool_call reports terminal status", () => {
const completed = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "read",
toolCallId: "tool-1",
status: "completed",
text: "ok",
}),
TS,
);
expect(completed[1]).toEqual({
kind: "tool_result",
ts: TS,
toolUseId: "tool-1",
toolName: "read",
content: "ok",
isError: false,
});
const failed = parseAcpxStdoutLine(
emit({
type: "acpx.tool_call",
name: "edit",
toolCallId: "tool-2",
status: "failed",
text: "permission denied",
}),
TS,
);
expect(failed[1]).toMatchObject({ kind: "tool_result", isError: true, content: "permission denied" });
});
it("renders acpx.result with summary fallback to stopReason", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }),
TS,
);
expect(entries[0]).toMatchObject({ kind: "result", text: "completed", subtype: "end_turn", isError: false });
});
it("treats acpx.error as a stderr entry", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.error", message: "auth required", code: "ACP_AUTH" }),
TS,
);
expect(entries).toEqual([{ kind: "stderr", ts: TS, text: "auth required" }]);
});
it("renders unknown acpx.* events as system entries", () => {
const entries = parseAcpxStdoutLine(
emit({ type: "acpx.misc", message: "unhandled" }),
TS,
);
expect(entries).toEqual([{ kind: "system", ts: TS, text: "unhandled" }]);
});
it("falls back to a stdout entry for non-JSON lines", () => {
const entries = parseAcpxStdoutLine("not json", TS);
expect(entries).toEqual([{ kind: "stdout", ts: TS, text: "not json" }]);
});
});
@@ -0,0 +1,158 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
function parseJson(line: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(line);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringify(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function pickToolUseId(parsed: Record<string, unknown>): string {
return (
asString(parsed.toolCallId) ||
asString(parsed.toolUseId) ||
asString(parsed.id)
);
}
function statusText(parsed: Record<string, unknown>): string {
const text = asString(parsed.text).trim();
const tag = asString(parsed.tag).trim();
const used = asNumber(parsed.used, -1);
const size = asNumber(parsed.size, -1);
const parts: string[] = [];
if (text) parts.push(text);
if (tag && !text) parts.push(tag);
if (used >= 0 && size > 0) parts.push(`(${used}/${size} ctx)`);
return parts.join(" ") || tag || "status";
}
export function parseAcpxStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = parseJson(line);
if (!parsed) return [{ kind: "stdout", ts, text: line }];
const type = asString(parsed.type);
if (type === "acpx.session") {
const agent = asString(parsed.agent, "acpx");
const mode = asString(parsed.mode);
const permissionMode = asString(parsed.permissionMode);
const tail = [mode, permissionMode].filter(Boolean).join(" / ");
return [{
kind: "init",
ts,
model: tail ? `${agent} (${tail})` : agent,
sessionId:
asString(parsed.acpSessionId) ||
asString(parsed.sessionId) ||
asString(parsed.runtimeSessionName),
}];
}
if (type === "acpx.text_delta") {
const text = asString(parsed.text);
if (!text) return [];
const channel = asString(parsed.channel) || asString(parsed.stream);
return [{
kind: channel === "thought" || channel === "thinking" ? "thinking" : "assistant",
ts,
text,
delta: true,
}];
}
if (type === "acpx.tool_call") {
const status = asString(parsed.status);
const text = asString(parsed.text);
const name = asString(parsed.name, "acp_tool");
const toolUseId = pickToolUseId(parsed);
const input =
parsed.input !== undefined
? parsed.input
: text || status
? { ...(text ? { text } : {}), ...(status ? { status } : {}) }
: {};
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts,
name,
toolUseId: toolUseId || undefined,
input,
},
];
if (status === "completed" || status === "failed" || status === "cancelled") {
entries.push({
kind: "tool_result",
ts,
toolUseId: toolUseId || name,
toolName: name,
content: text || status,
isError: status !== "completed",
});
}
return entries;
}
if (type === "acpx.tool_result") {
return [{
kind: "tool_result",
ts,
toolUseId: pickToolUseId(parsed) || asString(parsed.name, "acp_tool"),
toolName: asString(parsed.name) || undefined,
content: stringify(parsed.content ?? parsed.output ?? parsed.error),
isError: parsed.isError === true || parsed.error !== undefined,
}];
}
if (type === "acpx.status") {
return [{ kind: "system", ts, text: statusText(parsed) }];
}
if (type === "acpx.result") {
return [{
kind: "result",
ts,
text: asString(parsed.summary, asString(parsed.stopReason, asString(parsed.text))),
inputTokens: asNumber(parsed.inputTokens),
outputTokens: asNumber(parsed.outputTokens),
cachedTokens: asNumber(parsed.cachedTokens),
costUsd: asNumber(parsed.costUsd),
subtype: asString(parsed.subtype, asString(parsed.stopReason, "acpx.result")),
isError: parsed.isError === true,
errors: Array.isArray(parsed.errors)
? parsed.errors.map((error) => stringify(error)).filter(Boolean)
: [],
}];
}
if (type === "acpx.error") {
return [{ kind: "stderr", ts, text: asString(parsed.message, line) }];
}
if (type.startsWith("acpx.")) {
return [{ kind: "system", ts, text: asString(parsed.message, type) }];
}
return [{ kind: "stdout", ts, text: line }];
}
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
@@ -1,3 +1,5 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "claude_local";
export const label = "Claude Code (local)";
@@ -10,6 +12,19 @@ export const models = [
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use Claude Sonnet as the lower-cost Claude Code lane while preserving the agent's primary model.",
adapterConfig: {
model: "claude-sonnet-4-6",
effort: "low",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# claude_local agent configuration
Adapter: claude_local
@@ -0,0 +1,66 @@
import * as fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareClaudeConfigSeed } from "./claude-config.js";
describe("prepareClaudeConfigSeed", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
function createEnv(root: string, sourceDir: string): NodeJS.ProcessEnv {
return {
HOME: root,
PAPERCLIP_HOME: path.join(root, "paperclip-home"),
PAPERCLIP_INSTANCE_ID: "test-instance",
CLAUDE_CONFIG_DIR: sourceDir,
};
}
it("reuses the same snapshot path when the seeded files are unchanged", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-seed-"));
cleanupDirs.push(root);
const sourceDir = path.join(root, "claude-source");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({ theme: "light" }), "utf8");
const onLog = vi.fn(async () => {});
const env = createEnv(root, sourceDir);
const first = await prepareClaudeConfigSeed(env, onLog, "company-1");
const second = await prepareClaudeConfigSeed(env, onLog, "company-1");
expect(first).toBe(second);
await expect(fs.readFile(path.join(first, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "light" }));
});
it("keeps an existing snapshot intact when the seeded files change", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-race-"));
cleanupDirs.push(root);
const sourceDir = path.join(root, "claude-source");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({ theme: "light" }), "utf8");
const onLog = vi.fn(async () => {});
const env = createEnv(root, sourceDir);
const first = await prepareClaudeConfigSeed(env, onLog, "company-1");
await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({ theme: "dark" }), "utf8");
const second = await prepareClaudeConfigSeed(env, onLog, "company-1");
expect(second).not.toBe(first);
await expect(fs.readFile(path.join(first, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "light" }));
await expect(fs.readFile(path.join(second, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "dark" }));
});
});
@@ -0,0 +1,135 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
const SEEDED_SHARED_FILES = [
".credentials.json",
"credentials.json",
"settings.json",
"settings.local.json",
"CLAUDE.md",
] as const;
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
function isAlreadyExistsError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const code = "code" in error ? error.code : null;
return code === "EEXIST" || code === "ENOTEMPTY";
}
async function collectSeedFiles(sourceDir: string): Promise<Array<{ name: string; sourcePath: string }>> {
const files: Array<{ name: string; sourcePath: string }> = [];
for (const name of SEEDED_SHARED_FILES) {
const sourcePath = path.join(sourceDir, name);
if (!(await pathExists(sourcePath))) continue;
files.push({ name, sourcePath });
}
return files;
}
async function buildSeedSnapshotKey(files: Array<{ name: string; sourcePath: string }>): Promise<string> {
if (files.length === 0) return "empty";
const hash = createHash("sha256");
for (const file of files) {
hash.update(file.name);
hash.update("\0");
hash.update(await fs.readFile(file.sourcePath));
hash.update("\0");
}
return hash.digest("hex").slice(0, 16);
}
async function materializeSeedSnapshot(input: {
rootDir: string;
snapshotKey: string;
files: Array<{ name: string; sourcePath: string }>;
}): Promise<string> {
const targetDir = path.join(input.rootDir, input.snapshotKey);
if (await pathExists(targetDir)) {
return targetDir;
}
await fs.mkdir(input.rootDir, { recursive: true });
const stagingDir = await fs.mkdtemp(path.join(input.rootDir, ".tmp-"));
try {
for (const file of input.files) {
await fs.copyFile(file.sourcePath, path.join(stagingDir, file.name));
}
try {
await fs.rename(stagingDir, targetDir);
} catch (error) {
if (!isAlreadyExistsError(error)) {
throw error;
}
await fs.rm(stagingDir, { recursive: true, force: true });
}
} catch (error) {
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
return targetDir;
}
export function resolveSharedClaudeConfigDir(
env: NodeJS.ProcessEnv = process.env,
): string {
const fromEnv = nonEmpty(env.CLAUDE_CONFIG_DIR);
return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".claude");
}
export function resolveManagedClaudeConfigSeedDir(
env: NodeJS.ProcessEnv,
companyId?: string,
): string {
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
return companyId
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-config-seed")
: path.resolve(paperclipHome, "instances", instanceId, "claude-config-seed");
}
export async function prepareClaudeConfigSeed(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
companyId?: string,
): Promise<string> {
const sourceDir = resolveSharedClaudeConfigDir(env);
const targetRootDir = resolveManagedClaudeConfigSeedDir(env, companyId);
if (path.resolve(sourceDir) === path.resolve(targetRootDir)) {
return targetRootDir;
}
const copiedFiles = await collectSeedFiles(sourceDir);
const snapshotKey = await buildSeedSnapshotKey(copiedFiles);
const targetDir = await materializeSeedSnapshot({
rootDir: targetRootDir,
snapshotKey,
files: copiedFiles,
});
if (copiedFiles.length > 0) {
await onLog(
"stdout",
`[paperclip] Prepared Claude config seed "${targetDir}" from "${sourceDir}" (${copiedFiles.map((file) => file.name).join(", ")}).\n`,
);
} else {
await onLog(
"stdout",
`[paperclip] No local Claude config seed files were found in "${sourceDir}". Remote Claude auth may still require login.\n`,
);
}
return targetDir;
}
@@ -10,12 +10,15 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -36,6 +39,7 @@ import {
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
import {
parseClaudeStreamJson,
describeClaudeFailure,
@@ -45,6 +49,7 @@ import {
isClaudeTransientUpstreamError,
isClaudeUnknownSessionError,
} from "./parse.js";
import { prepareClaudeConfigSeed } from "./claude-config.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
@@ -316,6 +321,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const configEnv = parseObject(config.env);
const hasExplicitClaudeConfigDir =
typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
const runtimeConfig = await buildClaudeRuntimeConfig({
@@ -334,11 +342,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
loggedEnv: initialLoggedEnv,
timeoutSec,
graceSec,
extraArgs,
} = runtimeConfig;
let loggedEnv = initialLoggedEnv;
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const terminalResultCleanupGraceMs = Math.max(
0,
@@ -379,6 +388,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsContents: combinedInstructionsContents,
onLog,
});
const useManagedRemoteClaudeConfig =
executionTargetIsRemote &&
adapterExecutionTargetUsesManagedHome(executionTarget) &&
!hasExplicitClaudeConfigDir;
const claudeConfigSeedDir = useManagedRemoteClaudeConfig
? await prepareClaudeConfigSeed(process.env, onLog, agent.companyId)
: null;
const preparedExecutionTargetRuntime = executionTargetIsRemote
? await (async () => {
await onLog(
@@ -395,6 +411,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
localDir: promptBundle.addDir,
followSymlinks: true,
},
...(claudeConfigSeedDir
? [{
key: "config-seed",
localDir: claudeConfigSeedDir,
followSymlinks: true,
}]
: []),
],
});
})()
@@ -411,6 +434,63 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? path.posix.join(effectivePromptBundleAddDir, path.basename(promptBundle.instructionsFilePath))
: promptBundle.instructionsFilePath
: undefined;
const remoteClaudeRuntimeRoot = executionTargetIsRemote
? preparedExecutionTargetRuntime?.runtimeRootDir ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "claude")
: null;
const remoteClaudeConfigSeedDir = claudeConfigSeedDir && remoteClaudeRuntimeRoot
? preparedExecutionTargetRuntime?.assetDirs["config-seed"] ??
path.posix.join(remoteClaudeRuntimeRoot, "config-seed")
: null;
const remoteClaudeConfigDir = useManagedRemoteClaudeConfig && remoteClaudeRuntimeRoot
? path.posix.join(remoteClaudeRuntimeRoot, "config")
: null;
if (remoteClaudeConfigDir && remoteClaudeConfigSeedDir) {
env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
loggedEnv.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
await onLog(
"stdout",
`[paperclip] Materializing Claude auth/config into ${remoteClaudeConfigDir}.\n`,
);
await runAdapterExecutionTargetShellCommand(
runId,
executionTarget,
`mkdir -p ${shellQuote(remoteClaudeConfigDir)} && ` +
`if [ -d ${shellQuote(remoteClaudeConfigSeedDir)} ]; then ` +
`cp -R ${shellQuote(`${remoteClaudeConfigSeedDir}/.`)} ${shellQuote(remoteClaudeConfigDir)}/; ` +
`fi`,
{
cwd,
env,
timeoutSec: Math.max(timeoutSec, 15),
graceSec,
onLog,
},
);
}
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
adapterKey: "claude",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
if (remoteClaudeConfigDir) {
loggedEnv.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
}
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -766,6 +846,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",
@@ -9,11 +9,15 @@ import {
asNumber,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import path from "node:path";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
import { isBedrockModelId } from "./models.js";
@@ -56,10 +60,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "claude");
const cwd = asString(config.cwd, process.cwd());
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "claude_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({
code: "claude_cwd_valid",
level: "info",
@@ -81,7 +103,7 @@ export async function testEnvironment(
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
code: "claude_command_resolvable",
level: "info",
@@ -96,16 +118,21 @@ export async function testEnvironment(
});
}
// When probing a remote target, the Paperclip host's process.env does not
// reflect what the agent will actually see at runtime. Only consider env
// vars from the adapter config in that case; the probe itself will surface
// any auth issues on the remote box.
const considerHostEnv = !targetIsRemote;
const hasBedrock =
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
env.CLAUDE_CODE_USE_BEDROCK === "true" ||
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "1") ||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "true") ||
isNonEmpty(env.ANTHROPIC_BEDROCK_BASE_URL) ||
isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL);
(considerHostEnv && isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL));
const configApiKey = env.ANTHROPIC_API_KEY;
const hostApiKey = process.env.ANTHROPIC_API_KEY;
const hostApiKey = considerHostEnv ? process.env.ANTHROPIC_API_KEY : undefined;
if (hasBedrock) {
const source =
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
@@ -130,7 +157,7 @@ export async function testEnvironment(
detail: `Detected in ${source}.`,
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
});
} else {
} else if (!targetIsRemote) {
checks.push({
code: "claude_subscription_mode_possible",
level: "info",
@@ -172,8 +199,9 @@ export async function testEnvironment(
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
if (extraArgs.length > 0) args.push(...extraArgs);
const probe = await runChildProcess(
`claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
args,
{
@@ -66,8 +66,6 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
if (v.chrome) ac.chrome = true;
@@ -1,5 +1,8 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "codex_local";
export const label = "Codex (local)";
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const;
@@ -40,6 +43,19 @@ export const models = [
{ id: "codex-mini-latest", label: "Codex Mini" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use the lowest-cost known Codex local model lane without changing the primary model.",
adapterConfig: {
model: "gpt-5.3-codex-spark",
modelReasoningEffort: "low",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# codex_local agent configuration
Adapter: codex_local
@@ -8,12 +8,14 @@ import {
adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -369,6 +371,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
? () => preparedExecutionTargetRuntime.restoreWorkspace()
: null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
const remoteCodexHome = executionTargetIsRemote
? preparedExecutionTargetRuntime?.assetDirs.home ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home")
@@ -456,6 +459,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
adapterKey: "codex",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
}
}
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
@@ -780,6 +796,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial, false, false);
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",
@@ -6,11 +6,15 @@ import type {
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import path from "node:path";
import { parseCodexJsonl } from "./parse.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
@@ -57,10 +61,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "codex");
const cwd = asString(config.cwd, process.cwd());
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "codex_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({
code: "codex_cwd_valid",
level: "info",
@@ -82,7 +104,7 @@ export async function testEnvironment(
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
code: "codex_command_resolvable",
level: "info",
@@ -98,7 +120,7 @@ export async function testEnvironment(
}
const configOpenAiKey = env.OPENAI_API_KEY;
const hostOpenAiKey = process.env.OPENAI_API_KEY;
const hostOpenAiKey = targetIsRemote ? undefined : process.env.OPENAI_API_KEY;
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
checks.push({
@@ -107,7 +129,9 @@ export async function testEnvironment(
message: "OPENAI_API_KEY is set for Codex authentication.",
detail: `Detected in ${source}.`,
});
} else {
} else if (!targetIsRemote) {
// Local-only auth file check. On remote targets, the probe will surface
// any missing-auth errors directly from the remote `codex` invocation.
const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
if (codexAuth) {
@@ -150,8 +174,9 @@ export async function testEnvironment(
});
}
const probe = await runChildProcess(
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
args,
{
@@ -70,8 +70,6 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
ac.timeoutSec = 0;
@@ -1,5 +1,8 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "cursor";
export const label = "Cursor CLI (local)";
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
const CURSOR_FALLBACK_MODEL_IDS = [
@@ -46,6 +49,18 @@ const CURSOR_FALLBACK_MODEL_IDS = [
export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id }));
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use Cursor's known Codex mini model as the budget lane instead of assuming auto is cheap.",
adapterConfig: {
model: "gpt-5.1-codex-mini",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# cursor agent configuration
Adapter: cursor
@@ -80,4 +95,5 @@ Notes:
- Sessions are resumed with --resume when stored session cwd matches current cwd.
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
- Remote sandbox runs prepend "~/.local/bin" to PATH and prefer "~/.local/bin/cursor-agent" when the default Cursor entrypoint is requested, so standard E2B-style installs do not need hardcoded absolute command paths.
`;
@@ -10,6 +10,7 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
@@ -18,6 +19,7 @@ import {
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -41,6 +43,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
import { prepareCursorSandboxCommand } from "./remote-command.js";
import { normalizeCursorStreamLine } from "../shared/stream.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
@@ -199,7 +202,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "agent");
let command = asString(config.command, "agent");
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
const mode = normalizeMode(asString(config.mode, ""));
@@ -231,7 +234,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
let env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
@@ -299,6 +302,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
// Probe the sandbox before the managed-home override so we discover
// cursor-agent from the real system HOME (e.g. ~/.local/bin/cursor-agent).
// The managed HOME set later is for runtime isolation, not for finding the CLI.
const sandboxCommand = await prepareCursorSandboxCommand({
runId,
target: executionTarget,
command,
cwd,
env,
timeoutSec,
graceSec,
});
command = sandboxCommand.command;
env = sandboxCommand.env;
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
@@ -308,14 +327,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@@ -325,6 +342,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote) {
try {
@@ -344,6 +363,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
@@ -374,6 +394,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "cursor",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv: ensurePathInEnv({ ...process.env, ...env }),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -422,6 +460,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
notes.push("Auto-added --yolo to bypass interactive prompts.");
}
notes.push("Prompt is piped to Cursor via stdin.");
if (sandboxCommand.addedPathEntry) {
notes.push(`Remote sandbox runs prepend ${sandboxCommand.addedPathEntry} to PATH.`);
}
if (sandboxCommand.preferredCommandPath) {
notes.push(`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`);
}
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
@@ -636,6 +680,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
return toResult(initial);
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",
@@ -0,0 +1,160 @@
import path from "node:path";
import {
runAdapterExecutionTargetShellCommand,
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import { ensurePathInEnv } from "@paperclipai/adapter-utils/server-utils";
const DEFAULT_CURSOR_COMMAND_BASENAMES = new Set(["agent", "cursor-agent"]);
function commandBasename(command: string): string {
return command.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function prependPosixPathEntry(pathValue: string, entry: string): string {
const parts = pathValue.split(":").filter(Boolean);
if (parts.includes(entry)) return pathValue;
const cleaned = parts.join(":");
return cleaned.length > 0 ? `${entry}:${cleaned}` : entry;
}
type SandboxCursorRuntimeInfo = {
remoteSystemHomeDir: string | null;
preferredCommandPath: string | null;
};
function readMarkedValue(lines: string[], marker: string): string | null {
const matchedLine = lines.find((line) => line.startsWith(marker));
if (!matchedLine) return null;
const value = matchedLine.slice(marker.length).trim();
return value.length > 0 ? value : null;
}
async function readSandboxCursorRuntimeInfo(input: {
runId: string;
target: AdapterExecutionTarget;
command: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<SandboxCursorRuntimeInfo> {
const shouldCheckPreferredCommand = isDefaultCursorCommand(input.command) && !hasPathSeparator(input.command);
const homeMarker = "__PAPERCLIP_CURSOR_HOME__:";
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
try {
const result = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
[
`printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
shouldCheckPreferredCommand
? `if [ -x "$HOME/.local/bin/cursor-agent" ]; then printf ${JSON.stringify(`${preferredMarker}%s\\n`)} "$HOME/.local/bin/cursor-agent"; fi`
: "",
].filter(Boolean).join("; "),
{
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
},
);
if (result.timedOut || (result.exitCode ?? 1) !== 0) {
return {
remoteSystemHomeDir: null,
preferredCommandPath: null,
};
}
const lines = result.stdout.split(/\r?\n/);
return {
remoteSystemHomeDir: readMarkedValue(lines, homeMarker),
preferredCommandPath: readMarkedValue(lines, preferredMarker),
};
} catch {
return {
remoteSystemHomeDir: null,
preferredCommandPath: null,
};
}
}
export function isDefaultCursorCommand(command: string): boolean {
return DEFAULT_CURSOR_COMMAND_BASENAMES.has(commandBasename(command));
}
export type PreparedCursorSandboxCommand = {
command: string;
env: Record<string, string>;
remoteSystemHomeDir: string | null;
addedPathEntry: string | null;
preferredCommandPath: string | null;
};
export async function prepareCursorSandboxCommand(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
command: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<PreparedCursorSandboxCommand> {
if (input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
return {
command: input.command,
env: input.env,
remoteSystemHomeDir: null,
addedPathEntry: null,
preferredCommandPath: null,
};
}
const runtimeInfo = await readSandboxCursorRuntimeInfo({
runId: input.runId,
target: input.target,
command: input.command,
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
});
const remoteSystemHomeDir = runtimeInfo.remoteSystemHomeDir;
if (!remoteSystemHomeDir) {
return {
command: input.command,
env: input.env,
remoteSystemHomeDir: null,
addedPathEntry: null,
preferredCommandPath: null,
};
}
const remoteLocalBinDir = path.posix.join(remoteSystemHomeDir, ".local", "bin");
const runtimeEnv = ensurePathInEnv(input.env);
const currentPath = runtimeEnv.PATH ?? runtimeEnv.Path ?? "";
const nextPath = prependPosixPathEntry(currentPath, remoteLocalBinDir);
const env = nextPath === currentPath ? input.env : { ...input.env, PATH: nextPath };
if (!runtimeInfo.preferredCommandPath) {
return {
command: input.command,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
preferredCommandPath: null,
};
}
return {
command: runtimeInfo.preferredCommandPath,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
preferredCommandPath: runtimeInfo.preferredCommandPath,
};
}
@@ -7,16 +7,21 @@ import {
asString,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl } from "./parse.js";
import { isDefaultCursorCommand, prepareCursorSandboxCommand } from "./remote-command.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@@ -38,11 +43,6 @@ function firstNonEmptyLine(text: string): string {
);
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
@@ -94,11 +94,29 @@ export async function testEnvironment(
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "agent");
const cwd = asString(config.cwd, process.cwd());
let command = asString(config.command, "agent");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "cursor_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({
code: "cursor_cwd_valid",
level: "info",
@@ -114,13 +132,24 @@ export async function testEnvironment(
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
let env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const sandboxCommand = await prepareCursorSandboxCommand({
runId,
target,
command,
cwd,
env,
timeoutSec: 45,
graceSec: 5,
});
command = sandboxCommand.command;
env = sandboxCommand.env;
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
code: "cursor_command_resolvable",
level: "info",
@@ -136,7 +165,7 @@ export async function testEnvironment(
}
const configCursorApiKey = env.CURSOR_API_KEY;
const hostCursorApiKey = process.env.CURSOR_API_KEY;
const hostCursorApiKey = targetIsRemote ? undefined : process.env.CURSOR_API_KEY;
if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
checks.push({
@@ -145,7 +174,7 @@ export async function testEnvironment(
message: "CURSOR_API_KEY is set for Cursor authentication.",
detail: `Detected in ${source}.`,
});
} else {
} else if (!targetIsRemote) {
const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined;
const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null);
if (cursorAuth) {
@@ -170,13 +199,13 @@ export async function testEnvironment(
const canRunProbe =
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
if (canRunProbe) {
if (!commandLooksLike(command, "agent")) {
if (!isDefaultCursorCommand(command)) {
checks.push({
code: "cursor_hello_probe_skipped_custom_command",
level: "info",
message: "Skipped hello probe because command is not `agent`.",
message: "Skipped hello probe because command is not a default Cursor CLI entrypoint.",
detail: command,
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
});
} else {
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
@@ -192,8 +221,9 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
const probe = await runChildProcess(
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
args,
{
@@ -61,8 +61,6 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
const mode = normalizeMode(v.thinkingEffort);
if (mode) ac.mode = mode;
@@ -1,5 +1,8 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "gemini_local";
export const label = "Gemini CLI (local)";
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
export const models = [
@@ -11,6 +14,18 @@ export const models = [
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use Gemini Flash Lite as the budget Gemini CLI lane while preserving the primary model.",
adapterConfig: {
model: "gemini-2.5-flash-lite",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# gemini_local agent configuration
Adapter: gemini_local
@@ -11,6 +11,7 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
@@ -19,6 +20,7 @@ import {
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asBoolean,
@@ -268,7 +270,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
@@ -285,6 +287,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let remoteSkillsDir: string | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote) {
try {
@@ -304,6 +308,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
@@ -334,6 +339,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "gemini",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv: ensurePathInEnv({ ...process.env, ...env }),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -583,6 +606,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
await Promise.all([
paperclipBridge?.stop(),
restoreRemoteWorkspace?.(),
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
]);
@@ -9,12 +9,16 @@ import {
asNumber,
asString,
asStringArray,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
parseObject,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
@@ -48,10 +52,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "gemini");
const cwd = asString(config.cwd, process.cwd());
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "gemini_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({
code: "gemini_cwd_valid",
level: "info",
@@ -73,7 +95,7 @@ export async function testEnvironment(
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
code: "gemini_command_resolvable",
level: "info",
@@ -89,10 +111,10 @@ export async function testEnvironment(
}
const configGeminiApiKey = env.GEMINI_API_KEY;
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
const hostGeminiApiKey = targetIsRemote ? undefined : process.env.GEMINI_API_KEY;
const configGoogleApiKey = env.GOOGLE_API_KEY;
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
const hostGoogleApiKey = targetIsRemote ? undefined : process.env.GOOGLE_API_KEY;
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || (!targetIsRemote && process.env.GOOGLE_GENAI_USE_GCA === "true");
if (
isNonEmpty(configGeminiApiKey) ||
isNonEmpty(hostGeminiApiKey) ||
@@ -152,8 +174,9 @@ export async function testEnvironment(
}
if (extraArgs.length > 0) args.push(...extraArgs);
const probe = await runChildProcess(
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
args,
{
@@ -55,8 +55,6 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;
@@ -1,3 +1,5 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "opencode_local";
export const label = "OpenCode (local)";
@@ -11,6 +13,19 @@ export const models: Array<{ id: string; label: string }> = [
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
];
export const modelProfiles: AdapterModelProfileDefinition[] = [
{
key: "cheap",
label: "Cheap",
description: "Use OpenCode's known Codex mini model as the budget lane.",
adapterConfig: {
model: "openai/gpt-5.1-codex-mini",
variant: "low",
},
source: "adapter_default",
},
];
export const agentConfigurationDoc = `# opencode_local agent configuration
Adapter: opencode_local
@@ -10,6 +10,7 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
@@ -18,6 +19,7 @@ import {
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -234,7 +236,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
@@ -259,6 +261,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote) {
localSkillsDir = await buildOpenCodeSkillsDir(config);
@@ -285,6 +289,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
preparedRuntimeConfig.env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
@@ -311,6 +316,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "opencode",
hostApiToken: preparedRuntimeConfig.env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(preparedRuntimeConfig.env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv: Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -538,6 +565,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
await Promise.all([
paperclipBridge?.stop(),
restoreRemoteWorkspace?.(),
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
]);
@@ -8,11 +8,15 @@ import {
asString,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
@@ -58,10 +62,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "opencode");
const cwd = asString(config.cwd, process.cwd());
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "opencode_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: false,
});
checks.push({
code: "opencode_cwd_valid",
level: "info",
@@ -115,7 +137,7 @@ export async function testEnvironment(
});
} else {
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
code: "opencode_command_resolvable",
level: "info",
@@ -137,7 +159,19 @@ export async function testEnvironment(
let modelValidationPassed = false;
const configuredModel = asString(config.model, "").trim();
if (canRunProbe && configuredModel) {
// Model discovery and validation use local child processes against
// OpenCode's `models` subcommand and JSON config; these are not yet
// wired through the execution target. When probing a remote env, skip
// discovery/validation and rely on the remote hello probe to surface
// model/auth issues directly.
if (targetIsRemote && configuredModel) {
checks.push({
code: "opencode_model_validation_skipped_remote",
level: "info",
message: `Skipped local model validation; will be validated by the hello probe inside ${targetLabel}.`,
});
modelValidationPassed = true;
} else if (canRunProbe && configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
@@ -173,7 +207,7 @@ export async function testEnvironment(
});
}
}
} else if (canRunProbe && !configuredModel) {
} else if (!targetIsRemote && canRunProbe && !configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
@@ -207,7 +241,7 @@ export async function testEnvironment(
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
if (!configuredModel && !modelUnavailable) {
// No model configured skip model requirement if no model-related checks exist
} else if (configuredModel && canRunProbe) {
} else if (!targetIsRemote && configuredModel && canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
@@ -246,8 +280,9 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
args,
{
@@ -54,8 +54,6 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
+4
View File
@@ -1,8 +1,12 @@
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "pi_local";
export const label = "Pi (local)";
export const models: Array<{ id: string; label: string }> = [];
export const modelProfiles: AdapterModelProfileDefinition[] = [];
export const agentConfigurationDoc = `# pi_local agent configuration
Adapter: pi_local

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