mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Merge origin/master into search page branch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
a7c593ce95
226 files changed
+55254
-2082
No files matched your search
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -45,6 +45,15 @@ jobs:
|
||||
- name: Validate Dockerfile deps stage
|
||||
run: node ./scripts/check-docker-deps-stage.mjs
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Verify release package bootstrap for changed manifests
|
||||
run: |
|
||||
mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")
|
||||
PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \
|
||||
node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}"
|
||||
|
||||
- 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 }}")"
|
||||
@@ -76,11 +85,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
- name: Typecheck workspaces whose build scripts skip TypeScript
|
||||
run: pnpm run typecheck:build-gaps
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
- name: Run general test suites
|
||||
run: pnpm test:run:general
|
||||
|
||||
- name: Verify release registry test coverage
|
||||
run: pnpm run test:release-registry
|
||||
@@ -88,7 +97,76 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Release canary dry run
|
||||
verify_serialized_server:
|
||||
name: Verify serialized server suites (${{ matrix.shard_label }})
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard_index: 0
|
||||
shard_count: 4
|
||||
shard_label: 1/4
|
||||
- shard_index: 1
|
||||
shard_count: 4
|
||||
shard_label: 2/4
|
||||
- shard_index: 2
|
||||
shard_count: 4
|
||||
shard_label: 3/4
|
||||
- shard_index: 3
|
||||
shard_count: 4
|
||||
shard_label: 4/4
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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 --frozen-lockfile
|
||||
|
||||
- name: Run serialized server test shard
|
||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
||||
|
||||
canary_dry_run:
|
||||
name: Canary Dry Run
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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 --frozen-lockfile
|
||||
|
||||
# `release.sh` always executes its Step 2/7 workspace build, even when
|
||||
# `--skip-verify` bypasses the initial verification gate.
|
||||
- name: Release canary dry run via release.sh internal build
|
||||
run: |
|
||||
git checkout -B master HEAD
|
||||
git checkout -- pnpm-lock.yaml
|
||||
@@ -117,9 +195,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
@@ -89,6 +92,9 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
@@ -139,6 +145,9 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
@@ -177,6 +186,9 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
|
||||
@@ -176,6 +176,58 @@ That means:
|
||||
|
||||
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
|
||||
|
||||
## Release enrollment for new public packages
|
||||
|
||||
Paperclip does not auto-publish every non-private workspace package anymore.
|
||||
CI publishing is controlled by [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json).
|
||||
|
||||
When you add a new public package:
|
||||
|
||||
1. add it to the manifest and decide whether CI should publish it immediately
|
||||
2. if CI should publish it, bootstrap the package on npm before merge
|
||||
3. if CI should not publish it yet, keep `"publishFromCi": false`
|
||||
4. only enable `"publishFromCi": true` after npm trusted publishing is configured for that package
|
||||
|
||||
PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`.
|
||||
|
||||
### One-time bootstrap sequence for a new package
|
||||
|
||||
The first publish of a brand-new package still needs one human maintainer with npm write access.
|
||||
After that, trusted publishing can take over.
|
||||
|
||||
Example for `@paperclipai/adapter-acpx-local` from the repo root:
|
||||
|
||||
```bash
|
||||
# safe preview
|
||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local
|
||||
|
||||
# one-time first publish from an authenticated maintainer machine
|
||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456
|
||||
```
|
||||
|
||||
The helper script:
|
||||
|
||||
- checks that the package does not already exist on npm
|
||||
- builds the target package unless `--skip-build` is passed
|
||||
- runs `npm pack --dry-run` in the package directory
|
||||
- only runs the real `npm publish --access public` when `--publish --otp <code>` is provided
|
||||
|
||||
For the real `--publish` step, the maintainer machine must already be authenticated to npm.
|
||||
If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper.
|
||||
That local human auth is fine for the one-time bootstrap publish; we just do not want the same auth model inside CI.
|
||||
The helper now requires `--otp <code>` up front for `--publish`, so it fails before the real publish attempt if the one-time password is missing.
|
||||
|
||||
After that first publish succeeds:
|
||||
|
||||
1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local`
|
||||
2. go to `Settings` → `Trusted publishing`
|
||||
3. add repository `paperclipai/paperclip`
|
||||
4. set workflow filename to `release.yml`
|
||||
5. optionally go to `Settings` → `Publishing access` and enable `Require two-factor authentication and disallow tokens`
|
||||
6. keep `publishFromCi: true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
||||
|
||||
Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step is only the first package creation on npm.
|
||||
|
||||
## Rollback model
|
||||
|
||||
Rollback does not unpublish anything.
|
||||
|
||||
@@ -67,6 +67,27 @@ Why:
|
||||
- the single `release.yml` workflow handles both canary and stable publishing
|
||||
- GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side
|
||||
|
||||
### 2.2.1. Newly added public packages need a bootstrap phase
|
||||
|
||||
Trusted publishing is configured on the npm package itself, not at the repo scope.
|
||||
That means a brand-new public package must not be auto-enrolled into CI publishing until its npm package exists and its trusted publisher has been configured.
|
||||
|
||||
Repo policy:
|
||||
|
||||
1. add every non-private package to [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
||||
2. set `"publishFromCi": true` only when CI is expected to publish that package
|
||||
3. if the package is not ready for CI publishing yet, keep `"publishFromCi": false`
|
||||
4. complete the package bootstrap before merging any PR that changes a release-enabled new package
|
||||
|
||||
Bootstrap sequence for a new package:
|
||||
|
||||
1. publish the package once from a trusted maintainer machine using normal npm auth
|
||||
2. open that package on npm and add the `paperclipai/paperclip` trusted publisher for `.github/workflows/release.yml`
|
||||
3. rerun or dry-run the release flow as needed to confirm CI publishing now works
|
||||
4. only then enable `"publishFromCi": true`
|
||||
|
||||
PR CI enforces this by checking changed release-enabled package manifests against npm. That keeps `master` canary publishing healthy while preserving the no-long-lived-token model for normal CI releases.
|
||||
|
||||
### 2.3. Verify trusted publishing before removing old auth
|
||||
|
||||
After the workflows are live:
|
||||
|
||||
@@ -13,7 +13,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
||||
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
||||
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
|
||||
only under `/api/plugins/:pluginId/api/*`.
|
||||
- There is no host-provided shared React component kit for plugins yet.
|
||||
- The host provides a small shared React component kit through
|
||||
`@paperclipai/plugin-sdk/ui`; use it for common Paperclip controls before
|
||||
building custom versions.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## Scaffold a plugin
|
||||
@@ -168,6 +170,187 @@ Mount surfaces currently wired in the host include:
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
## Shared host components
|
||||
|
||||
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
|
||||
Paperclip-native control. The host owns the implementation, so plugins inherit
|
||||
the board's current styling, ordering, recent selections, and dark-mode behavior
|
||||
without importing `ui/src` internals.
|
||||
|
||||
Currently exposed components include:
|
||||
|
||||
- `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown.
|
||||
- `FileTree` for serializable file and directory trees.
|
||||
- `IssuesList` for a native company-scoped issue table.
|
||||
- `AssigneePicker` for the same agent/user selector used in the new issue pane.
|
||||
Use the controlled `value` format `agent:<id>`, `user:<id>`, or `""`.
|
||||
- `ProjectPicker` for the same project selector used in the new issue pane.
|
||||
Use the controlled project id value, or `""` for no project.
|
||||
- `ManagedRoutinesList` for plugin-owned routine settings pages.
|
||||
|
||||
```tsx
|
||||
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function PluginAssignmentControls({ companyId }: { companyId: string }) {
|
||||
const [assignee, setAssignee] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssigneePicker
|
||||
companyId={companyId}
|
||||
value={assignee}
|
||||
onChange={(value) => setAssignee(value)}
|
||||
/>
|
||||
<ProjectPicker
|
||||
companyId={companyId}
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## File and path UI
|
||||
|
||||
Plugin UI often needs to render a file tree, accept a folder path, or browse a
|
||||
project workspace. There are three different surfaces for that, and they map to
|
||||
different trust and data-flow boundaries. Pick the surface that matches the
|
||||
data the plugin actually has.
|
||||
|
||||
### When to use the shared `FileTree`
|
||||
|
||||
Use `FileTree` from `@paperclipai/plugin-sdk/ui` whenever the plugin only needs
|
||||
to render a serializable file/directory list and react to selection or
|
||||
expand/collapse. The host owns the implementation, so plugin UI inherits the
|
||||
board's icons, indent, focus ring, and dark-mode styling without importing host
|
||||
internals.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
FileTree,
|
||||
type FileTreeNode,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const nodes: FileTreeNode[] = [
|
||||
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
|
||||
{
|
||||
name: "wiki",
|
||||
path: "wiki",
|
||||
kind: "dir",
|
||||
children: [
|
||||
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function WikiTree() {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["wiki"]));
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={selected}
|
||||
expandedPaths={expanded}
|
||||
onSelectFile={(path) => setSelected(path)}
|
||||
onToggleDir={(path) =>
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
next.has(path) ? next.delete(path) : next.add(path);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Good fits:
|
||||
|
||||
- LLM Wiki page navigation in `packages/plugins/plugin-llm-wiki` builds a
|
||||
`FileTreeNode[]` from worker query results and renders it through `FileTree`.
|
||||
- The example `plugin-file-browser-example` lazily fetches a directory's
|
||||
children through a `loadFileList` action when `onToggleDir` fires, then
|
||||
merges the children into the local tree state — letting the shared component
|
||||
handle rendering and selection.
|
||||
|
||||
Boundary rules:
|
||||
|
||||
- Keep the prop surface serializable (`nodes`, `expandedPaths`, `checkedPaths`,
|
||||
`fileBadges`, `fileTones`). Do not pass arbitrary render functions across the
|
||||
plugin/host boundary in v1; the supported escape hatches are
|
||||
`fileBadges` (status pill keyed by path) and `fileTones` (row tone keyed by
|
||||
path).
|
||||
- Do not import the host's `FileTree.tsx` or any `ui/src/*` module. The SDK
|
||||
declaration is the only supported import path for plugin UI.
|
||||
- The shared `FileTree` is for rendering and selection. Plugin-specific editors,
|
||||
ingest flows, query forms, and lint runs stay inside the plugin and do not
|
||||
belong as `FileTree` props.
|
||||
|
||||
### When to declare `localFolders`
|
||||
|
||||
When the plugin needs operator-configured filesystem roots — typically for
|
||||
trusted local plugins like wiki tooling — declare `localFolders[]` on the
|
||||
manifest and add the `local.folders` capability. The host renders a settings
|
||||
surface for the operator to set the absolute path, validates the path
|
||||
server-side (containment, symlinks, required files/directories), and exposes
|
||||
`ctx.localFolders.readText()` and `ctx.localFolders.writeTextAtomic()` in the
|
||||
worker.
|
||||
|
||||
```ts
|
||||
export const manifest = {
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "pages"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- The data lives outside any project workspace.
|
||||
- Reads and writes need company-scoped configuration.
|
||||
- The operator picks the path once in plugin settings and the worker resolves
|
||||
files relative to that root.
|
||||
|
||||
Do not use `localFolders` to grant the UI direct browser-side access to the
|
||||
filesystem — there is no such capability. The browser still goes through the
|
||||
worker via `getData` / `performAction`, and the worker only exposes paths it
|
||||
chose to expose.
|
||||
|
||||
### When to keep worker-mediated project workspace browsing
|
||||
|
||||
When the data lives inside an existing project workspace, keep the browsing
|
||||
flow worker-mediated:
|
||||
|
||||
- The worker uses `ctx.projects.listWorkspaces()` to resolve the workspace
|
||||
path, then reads its filesystem with normal Node APIs.
|
||||
- The plugin UI calls a `getData` handler for the root listing and an action
|
||||
for lazy children, then renders them through `FileTree`.
|
||||
- The worker is the only side that touches the disk. The browser receives a
|
||||
serializable tree and never sees raw absolute paths it can replay.
|
||||
|
||||
The example `plugin-file-browser-example` is the reference for this pattern:
|
||||
the worker registers `fileList` (data) and `loadFileList` (action) over the
|
||||
same handler, and the UI uses the action for on-toggle directory loading so the
|
||||
shared `FileTree` stays the rendering surface.
|
||||
|
||||
### Mixing surfaces
|
||||
|
||||
A single plugin can use more than one of these. The LLM Wiki uses
|
||||
`localFolders` for its content root, then renders the resulting page list
|
||||
through `FileTree`. The file browser example uses `ctx.projects.listWorkspaces`
|
||||
to pick a workspace and renders its on-disk tree through `FileTree` with lazy
|
||||
loading. Pick the boundary per data source, not per plugin.
|
||||
|
||||
## Company routes
|
||||
|
||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||
|
||||
@@ -27,7 +27,7 @@ Current limitations to keep in mind:
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||
- The current runtime ships a small host-provided plugin UI component kit through `@paperclipai/plugin-sdk/ui`, but does not support plugin asset uploads/reads yet. Treat plugin asset APIs as future-scope ideas, not current implementation promises.
|
||||
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
|
||||
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
|
||||
API routes.
|
||||
@@ -976,13 +976,23 @@ export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
|
||||
The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides:
|
||||
|
||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`
|
||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`, `useHostNavigation()`
|
||||
- **Design tokens**: colors, spacing, typography, shadows matching the host theme
|
||||
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
|
||||
- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps`
|
||||
|
||||
Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
|
||||
|
||||
`useHostNavigation()` is the supported way for plugin UI to navigate to
|
||||
Paperclip-internal pages. It exposes `resolveHref(to)`, `navigate(to,
|
||||
options?)`, and `linkProps(to, options?)`. Plugin links should prefer
|
||||
`linkProps()` so anchors keep real `href` values for copy-link, modifier-click,
|
||||
middle-click, and open-in-new-tab behavior while plain left-clicks route through
|
||||
the host SPA router. The host resolves company-scoped paths against the active
|
||||
company prefix without double-prefixing already-prefixed paths. Plugin UI should
|
||||
not use raw same-origin `href`s or `window.location.assign()` for internal
|
||||
Paperclip navigation because those can force a full document reload.
|
||||
|
||||
### 19.0.2 Bundle Isolation
|
||||
|
||||
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
|
||||
@@ -1062,6 +1072,11 @@ The host SDK ships shared components that plugins can import to quickly build UI
|
||||
| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
|
||||
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
|
||||
| `Spinner` | Loading indicator | Data fetch states |
|
||||
| `FileTree` | Host-styled file/directory tree | Wiki pages, workspace files, import previews |
|
||||
| `IssuesList` | Host issue list | Plugin pages that need a native issue view |
|
||||
| `AssigneePicker` | Host assignee picker for agents and board users | Creating issues, assigning routines, filtering work |
|
||||
| `ProjectPicker` | Host project picker | Creating issues, scoping dashboards, filtering work |
|
||||
| `ManagedRoutinesList` | Host routine list | Plugin settings pages that manage routines |
|
||||
|
||||
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
|
||||
|
||||
|
||||
+19
-2
@@ -75,11 +75,28 @@ Fields:
|
||||
```
|
||||
PATCH /api/routines/{routineId}
|
||||
{
|
||||
"status": "paused"
|
||||
"status": "paused",
|
||||
"baseRevisionId": "{latestRevisionId}"
|
||||
}
|
||||
```
|
||||
|
||||
All fields from create are updatable. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.**
|
||||
All fields from create are updatable. `baseRevisionId` is optional for backward compatibility; when provided, stale values return `409 Conflict` with the current revision id. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.**
|
||||
|
||||
## List Revisions
|
||||
|
||||
```
|
||||
GET /api/routines/{routineId}/revisions
|
||||
```
|
||||
|
||||
Returns append-only routine definition revisions newest first. Snapshots include routine fields and safe trigger metadata only; webhook secret values and `secretId` are never returned.
|
||||
|
||||
## Restore Revision
|
||||
|
||||
```
|
||||
POST /api/routines/{routineId}/revisions/{revisionId}/restore
|
||||
```
|
||||
|
||||
Restores a historical routine definition by creating a new latest revision copied from the selected revision. Historical revision rows, routine run history, and activity history are preserved. If restoring a deleted webhook trigger requires recreating it, the response can include one-time replacement secret material for that trigger.
|
||||
|
||||
## Add Trigger
|
||||
|
||||
|
||||
+5
-1
@@ -15,9 +15,12 @@
|
||||
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
|
||||
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||
"typecheck:build-gaps": "pnpm run preflight:workspace-links && node scripts/run-typecheck-build-gaps.mjs",
|
||||
"test": "pnpm run test:run",
|
||||
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
||||
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
|
||||
"test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general",
|
||||
"test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized",
|
||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||
"issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts",
|
||||
@@ -30,13 +33,14 @@
|
||||
"release:stable": "./scripts/release.sh stable",
|
||||
"release:github": "./scripts/create-github-release.sh",
|
||||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"docs:dev": "cd docs && npx mintlify dev",
|
||||
"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:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e: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",
|
||||
|
||||
@@ -55,9 +55,15 @@ describe("command managed runtime", () => {
|
||||
...process.env,
|
||||
...input.env,
|
||||
};
|
||||
const command = input.command === "sh" ? "/bin/sh" : input.command;
|
||||
const command =
|
||||
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
|
||||
const args = [...(input.args ?? [])];
|
||||
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
|
||||
if (
|
||||
input.stdin != null &&
|
||||
(input.command === "sh" || input.command === "bash") &&
|
||||
args[0] === "-lc" &&
|
||||
typeof args[1] === "string"
|
||||
) {
|
||||
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
||||
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type SandboxManagedRuntimeClient,
|
||||
type SandboxRemoteExecutionSpec,
|
||||
} from "./sandbox-managed-runtime.js";
|
||||
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
||||
import type { RunProcessResult } from "./server-utils.js";
|
||||
|
||||
export interface CommandManagedRuntimeRunner {
|
||||
@@ -23,10 +24,10 @@ export interface CommandManagedRuntimeRunner {
|
||||
|
||||
export interface CommandManagedRuntimeSpec {
|
||||
providerKey?: string | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
timeoutMs?: number | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
}
|
||||
|
||||
export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset;
|
||||
@@ -58,10 +59,12 @@ export function createCommandManagedRuntimeClient(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
timeoutMs: number;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
}): SandboxManagedRuntimeClient {
|
||||
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
||||
const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
command: shellCommand,
|
||||
args: ["-lc", script],
|
||||
cwd: input.remoteCwd,
|
||||
stdin: opts.stdin,
|
||||
@@ -112,7 +115,7 @@ export function createCommandManagedRuntimeClient(input: {
|
||||
},
|
||||
remove: async (remotePath) => {
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
command: shellCommand,
|
||||
args: ["-lc", `rm -rf ${shellQuote(remotePath)}`],
|
||||
cwd: input.remoteCwd,
|
||||
timeoutMs: input.timeoutMs,
|
||||
@@ -121,7 +124,7 @@ export function createCommandManagedRuntimeClient(input: {
|
||||
},
|
||||
run: async (command, options) => {
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
command: shellCommand,
|
||||
args: ["-lc", command],
|
||||
cwd: input.remoteCwd,
|
||||
timeoutMs: options.timeoutMs,
|
||||
@@ -141,6 +144,8 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
preserveAbsentOnRestore?: string[];
|
||||
assets?: CommandManagedRuntimeAsset[];
|
||||
installCommand?: string | null;
|
||||
/** When provided alongside `installCommand`, skip the install if `command -v <detectCommand>` succeeds. */
|
||||
detectCommand?: string | null;
|
||||
}): Promise<PreparedSandboxManagedRuntime> {
|
||||
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
|
||||
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
@@ -151,22 +156,62 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
remoteCwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
apiKey: null,
|
||||
paperclipApiUrl: input.spec.paperclipApiUrl ?? null,
|
||||
};
|
||||
const client = createCommandManagedRuntimeClient({
|
||||
runner: input.runner,
|
||||
remoteCwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
shellCommand: input.spec.shellCommand,
|
||||
});
|
||||
const shellCommand = preferredShellForSandbox(input.spec.shellCommand);
|
||||
|
||||
if (input.installCommand?.trim()) {
|
||||
const installCommand = input.installCommand.trim();
|
||||
const detectCommand = input.detectCommand?.trim();
|
||||
// Skip the install when the binary is already on PATH. Without this
|
||||
// probe the install runs unconditionally on every execute() call (and
|
||||
// also runs a second time after `ensureAdapterExecutionTargetCommandResolvable`
|
||||
// has already installed it during the resolvability gate).
|
||||
if (detectCommand) {
|
||||
const probe = await input.runner.execute({
|
||||
command: shellCommand,
|
||||
args: ["-lc", `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`],
|
||||
cwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!probe.timedOut && (probe.exitCode ?? 1) === 0) {
|
||||
return await prepareSandboxManagedRuntime({
|
||||
spec: runtimeSpec,
|
||||
client,
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir,
|
||||
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await input.runner.execute({
|
||||
command: "sh",
|
||||
args: ["-lc", input.installCommand.trim()],
|
||||
command: shellCommand,
|
||||
args: ["-lc", installCommand],
|
||||
cwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
});
|
||||
requireSuccessfulResult(result, input.installCommand.trim());
|
||||
// A failed install is not always fatal: the CLI may already be on PATH
|
||||
// from a previous lease, the template image, or another path entry. Log
|
||||
// and continue rather than aborting the agent run; downstream code that
|
||||
// exec's the CLI will surface a clear "command not found" if it is in
|
||||
// fact missing. The test path's `maybeRunSandboxInstallCommand` already
|
||||
// honors this contract — keep them consistent.
|
||||
if (result.timedOut || (result.exitCode ?? 0) !== 0) {
|
||||
const tail = (text: string) =>
|
||||
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480);
|
||||
const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`;
|
||||
console.warn(
|
||||
`[paperclip] managed-runtime install command ${reason}: ${installCommand} :: ${tail(result.stderr || result.stdout)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await prepareSandboxManagedRuntime({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetToRemoteSpec,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
@@ -39,7 +40,8 @@ describe("sandbox adapter execution targets", () => {
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
return runChildProcess(`sandbox-run-${counter}`, input.command, input.args ?? [], {
|
||||
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
||||
return runChildProcess(`sandbox-run-${counter}`, command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
stdin: input.stdin,
|
||||
@@ -103,7 +105,6 @@ describe("sandbox adapter execution targets", () => {
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd: "/workspace",
|
||||
paperclipTransport: "bridge",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +141,68 @@ describe("sandbox adapter execution targets", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("treats SSH targets as bridge-only", () => {
|
||||
const target = {
|
||||
kind: "remote" as const,
|
||||
transport: "ssh" as const,
|
||||
remoteCwd: "/workspace",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteWorkspacePath: "/workspace",
|
||||
remoteCwd: "/workspace",
|
||||
privateKey: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(adapterExecutionTargetUsesPaperclipBridge(target)).toBe(true);
|
||||
expect(adapterExecutionTargetSessionIdentity(target)).toEqual({
|
||||
transport: "ssh",
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteCwd: "/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the provider-declared shell for sandbox helper commands", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "/home/sandbox",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "custom-provider",
|
||||
shellCommand: "bash",
|
||||
remoteCwd: "/workspace",
|
||||
runner,
|
||||
};
|
||||
|
||||
await runAdapterExecutionTargetShellCommand("run-2b", target, 'printf %s "$HOME"', {
|
||||
cwd: "/local/workspace",
|
||||
env: {},
|
||||
timeoutSec: 7,
|
||||
});
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "bash",
|
||||
args: ["-lc", 'printf %s "$HOME"'],
|
||||
cwd: "/workspace",
|
||||
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);
|
||||
@@ -174,7 +237,6 @@ describe("sandbox adapter execution targets", () => {
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd,
|
||||
paperclipTransport: "bridge",
|
||||
runner: createLocalSandboxRunner(),
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
@@ -252,7 +314,6 @@ describe("sandbox adapter execution targets", () => {
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd,
|
||||
paperclipTransport: "bridge",
|
||||
runner: createLocalSandboxRunner(),
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as ssh from "./ssh.js";
|
||||
import {
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
} from "./execution-target.js";
|
||||
@@ -161,6 +162,80 @@ describe("runAdapterExecutionTargetShellCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("runs install commands for sandbox targets", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId: "run-install",
|
||||
target: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
remoteCwd: "/remote/workspace",
|
||||
runner,
|
||||
},
|
||||
installCommand: "npm install -g @google/gemini-cli",
|
||||
cwd: "/local/workspace",
|
||||
env: { PATH: "/usr/bin" },
|
||||
timeoutSec: 30,
|
||||
});
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "sh",
|
||||
args: ["-lc", "npm install -g @google/gemini-cli"],
|
||||
cwd: "/remote/workspace",
|
||||
env: { PATH: "/usr/bin" },
|
||||
timeoutMs: 30_000,
|
||||
}));
|
||||
});
|
||||
|
||||
it("skips install commands for SSH targets", async () => {
|
||||
const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId: "run-skip",
|
||||
target: {
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
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,
|
||||
},
|
||||
},
|
||||
installCommand: "npm install -g @google/gemini-cli",
|
||||
cwd: "/tmp/local",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(runSshCommandSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAdapterExecutionTargetCwd", () => {
|
||||
const sshTarget = {
|
||||
kind: "remote" as const,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
startSandboxCallbackBridgeServer,
|
||||
startSandboxCallbackBridgeWorker,
|
||||
} from "./sandbox-callback-bridge.js";
|
||||
import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
|
||||
import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
|
||||
import {
|
||||
ensureCommandResolvable,
|
||||
resolveCommandForLogs,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type RunProcessResult,
|
||||
type TerminalResultCleanupOptions,
|
||||
} from "./server-utils.js";
|
||||
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
||||
|
||||
export interface AdapterLocalExecutionTarget {
|
||||
kind: "local";
|
||||
@@ -39,7 +40,6 @@ export interface AdapterSshExecutionTarget {
|
||||
environmentId?: string | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
paperclipApiUrl?: string | null;
|
||||
spec: SshRemoteExecutionSpec;
|
||||
}
|
||||
|
||||
@@ -47,11 +47,10 @@ export interface AdapterSandboxExecutionTarget {
|
||||
kind: "remote";
|
||||
transport: "sandbox";
|
||||
providerKey?: string | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
environmentId?: string | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
paperclipApiUrl?: string | null;
|
||||
paperclipTransport?: "direct" | "bridge";
|
||||
timeoutMs?: number | null;
|
||||
runner?: CommandManagedRuntimeRunner;
|
||||
}
|
||||
@@ -126,13 +125,9 @@ function resolveDefaultPaperclipApiUrl(): string {
|
||||
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 isBridgeDebugEnabled(env: NodeJS.ProcessEnv): boolean {
|
||||
const value = env.PAPERCLIP_BRIDGE_DEBUG?.trim().toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes";
|
||||
}
|
||||
|
||||
function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
|
||||
@@ -180,21 +175,10 @@ export function resolveAdapterExecutionTargetCwd(
|
||||
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";
|
||||
return target?.kind === "remote";
|
||||
}
|
||||
|
||||
export function describeAdapterExecutionTarget(
|
||||
@@ -214,13 +198,42 @@ function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandMan
|
||||
);
|
||||
}
|
||||
|
||||
function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" {
|
||||
return preferredShellForSandbox(target.shellCommand);
|
||||
}
|
||||
|
||||
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget;
|
||||
|
||||
function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecutionTarget): CommandManagedRuntimeRunner {
|
||||
if (target.transport === "ssh") {
|
||||
return createSshCommandManagedRuntimeRunner({
|
||||
spec: target.spec,
|
||||
defaultCwd: target.remoteCwd,
|
||||
maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4,
|
||||
});
|
||||
}
|
||||
return requireSandboxRunner(target);
|
||||
}
|
||||
|
||||
function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecutionTarget): "bash" | "sh" {
|
||||
return target.transport === "ssh" ? "sh" : preferredSandboxShell(target);
|
||||
}
|
||||
|
||||
function adapterExecutionTargetTimeoutMs(
|
||||
target: AdapterCommandCapableExecutionTarget,
|
||||
): number | null | undefined {
|
||||
return target.transport === "sandbox" ? target.timeoutMs : undefined;
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: { installCommand?: string | null } = {},
|
||||
) {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
await ensureSandboxCommandResolvable(command, target, options.installCommand?.trim() || null);
|
||||
return;
|
||||
}
|
||||
await ensureCommandResolvable(command, cwd, env, {
|
||||
@@ -228,6 +241,82 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
});
|
||||
}
|
||||
|
||||
async function probeSandboxCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterSandboxExecutionTarget,
|
||||
): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> {
|
||||
const runner = requireSandboxRunner(target);
|
||||
const probeScript = `command -v ${shellQuote(command)}`;
|
||||
const result = await runner.execute({
|
||||
command: "sh",
|
||||
args: ["-c", probeScript],
|
||||
cwd: target.remoteCwd,
|
||||
timeoutMs: target.timeoutMs ?? 15_000,
|
||||
});
|
||||
return {
|
||||
resolved: !result.timedOut && (result.exitCode ?? 1) === 0,
|
||||
timedOut: result.timedOut,
|
||||
stderr: result.stderr.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSandboxCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterSandboxExecutionTarget,
|
||||
installCommand: string | null,
|
||||
): Promise<void> {
|
||||
// Probe whether the binary is resolvable inside the sandbox. We previously
|
||||
// short-circuited this for sandbox targets, which let the caller report a
|
||||
// success message even when the CLI was missing from the image. Now we run
|
||||
// a real `command -v` through the same runner the hello probe will use, so
|
||||
// the first step honestly reflects whether the binary is on PATH. The
|
||||
// sandbox provider is responsible for sourcing login profiles (e2b mirrors
|
||||
// SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH.
|
||||
let probe = await probeSandboxCommandResolvable(command, target);
|
||||
if (probe.resolved) return;
|
||||
if (probe.timedOut) {
|
||||
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
|
||||
}
|
||||
|
||||
// If the caller supplied an install command, attempt the install once via
|
||||
// the sandbox runner (which the sandbox provider wraps in a login shell)
|
||||
// and re-probe before reporting failure. This lets fresh sandbox leases
|
||||
// bring up the CLI before the resolvability gate, mirroring the test path.
|
||||
let installFailureDetail: string | null = null;
|
||||
if (installCommand) {
|
||||
const runner = requireSandboxRunner(target);
|
||||
try {
|
||||
const installResult = await runner.execute({
|
||||
command: "sh",
|
||||
args: ["-lc", installCommand],
|
||||
cwd: target.remoteCwd,
|
||||
timeoutMs: target.timeoutMs ?? 300_000,
|
||||
});
|
||||
if (installResult.timedOut) {
|
||||
installFailureDetail = `install command timed out: ${installCommand}`;
|
||||
} else if ((installResult.exitCode ?? 0) !== 0) {
|
||||
const tail = (text: string) =>
|
||||
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-2).join(" | ").slice(0, 240);
|
||||
const reason = tail(installResult.stderr || installResult.stdout) || `exit ${installResult.exitCode ?? "?"}`;
|
||||
installFailureDetail = `install command exited ${installResult.exitCode ?? "?"}: ${reason}`;
|
||||
}
|
||||
} catch (err) {
|
||||
installFailureDetail = `install command threw: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
probe = await probeSandboxCommandResolvable(command, target);
|
||||
if (probe.resolved) return;
|
||||
if (probe.timedOut) {
|
||||
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
|
||||
}
|
||||
}
|
||||
|
||||
const probeStderr = probe.stderr.length > 0 ? ` probe stderr: ${probe.stderr}` : "";
|
||||
const installDetail = installFailureDetail ? `; ${installFailureDetail}` : "";
|
||||
throw new Error(
|
||||
`Command "${command}" is not installed or not on PATH in the sandbox environment${installDetail}.${probeStderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveAdapterExecutionTargetCommandForLogs(
|
||||
command: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
@@ -341,8 +430,9 @@ export async function runAdapterExecutionTargetShellCommand(
|
||||
}
|
||||
}
|
||||
|
||||
const shellCommand = preferredSandboxShell(target);
|
||||
return await requireSandboxRunner(target).execute({
|
||||
command: "sh",
|
||||
command: shellCommand,
|
||||
args: ["-lc", command],
|
||||
cwd: target.remoteCwd,
|
||||
env: options.env,
|
||||
@@ -366,6 +456,111 @@ export async function runAdapterExecutionTargetShellCommand(
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdapterSandboxInstallCommandCheck {
|
||||
code: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
detail?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
// Best-effort run of an adapter-supplied install command on a sandbox target
|
||||
// before the resolvability + hello probe. Returns null for non-sandbox
|
||||
// targets so callers can no-op. Returns a structured check otherwise — never
|
||||
// throws — so the rest of the test still runs and reports the post-install
|
||||
// state honestly. Caller pushes the check into its result array; the test
|
||||
// report shows whether install was attempted and what came back.
|
||||
export async function maybeRunSandboxInstallCommand(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
adapterKey: string;
|
||||
installCommand: string;
|
||||
/** When provided, skip the install if `command -v <detectCommand>` succeeds. */
|
||||
detectCommand?: string | null;
|
||||
env?: Record<string, string>;
|
||||
timeoutSec?: number;
|
||||
}): Promise<AdapterSandboxInstallCommandCheck | null> {
|
||||
const { target, adapterKey, installCommand } = input;
|
||||
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = installCommand.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
const code = `${adapterKey}_install_command_run`;
|
||||
|
||||
// Skip install when the binary is already on PATH. Avoids running
|
||||
// network-dependent installers (e.g. `curl ... | bash`) on every test
|
||||
// probe when the CLI is preinstalled on the lease/template.
|
||||
const detectCommand = input.detectCommand?.trim();
|
||||
if (detectCommand) {
|
||||
try {
|
||||
const probe = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
target,
|
||||
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
|
||||
{
|
||||
cwd: target.remoteCwd,
|
||||
env: input.env ?? {},
|
||||
timeoutSec: 30,
|
||||
graceSec: 5,
|
||||
},
|
||||
);
|
||||
if (!probe.timedOut && probe.exitCode === 0) {
|
||||
return {
|
||||
code,
|
||||
level: "info",
|
||||
message: `${detectCommand} already on PATH; skipped install.`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to actually running the install — failure to probe
|
||||
// is not a reason to skip the install gate.
|
||||
}
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runAdapterExecutionTargetShellCommand(input.runId, target, trimmed, {
|
||||
cwd: target.remoteCwd,
|
||||
env: input.env ?? {},
|
||||
timeoutSec: input.timeoutSec ?? 240,
|
||||
graceSec: 10,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
code,
|
||||
level: "warn",
|
||||
message: "Install command threw before completion.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
const tail = (text: string) =>
|
||||
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480);
|
||||
if (result.timedOut) {
|
||||
return {
|
||||
code,
|
||||
level: "warn",
|
||||
message: `Install command timed out: ${trimmed}`,
|
||||
detail: tail(result.stderr || result.stdout),
|
||||
};
|
||||
}
|
||||
if ((result.exitCode ?? 1) === 0) {
|
||||
return {
|
||||
code,
|
||||
level: "info",
|
||||
message: `Install command ran: ${trimmed}`,
|
||||
...(tail(result.stdout) ? { detail: tail(result.stdout) } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
code,
|
||||
level: "warn",
|
||||
message: `Install command exited ${result.exitCode}: ${trimmed}`,
|
||||
detail: tail(result.stderr || result.stdout),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAdapterExecutionTargetHomeDir(
|
||||
runId: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
@@ -381,6 +576,91 @@ export async function readAdapterExecutionTargetHomeDir(
|
||||
return homeDir.length > 0 ? homeDir : null;
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
installCommand?: string | null;
|
||||
detectCommand?: string | null;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec?: number;
|
||||
graceSec?: number;
|
||||
onLog?: AdapterExecutionTargetShellOptions["onLog"];
|
||||
}): Promise<void> {
|
||||
const installCommand = input.installCommand?.trim();
|
||||
if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
|
||||
return;
|
||||
}
|
||||
|
||||
const detectCommand = input.detectCommand?.trim();
|
||||
if (detectCommand) {
|
||||
const probe = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
input.target,
|
||||
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: input.timeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
},
|
||||
);
|
||||
if (!probe.timedOut && probe.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
input.target,
|
||||
installCommand,
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: input.timeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
onLog: input.onLog,
|
||||
},
|
||||
);
|
||||
|
||||
// A failed or timed-out install is not necessarily fatal: the CLI may already
|
||||
// be on PATH from a previous lease's install, the template image, or another
|
||||
// path entry. Re-run the detect probe (when one is configured) so a transient
|
||||
// install failure does not abort the agent run when the binary is reachable.
|
||||
const installFailed = result.timedOut || (result.exitCode ?? 0) !== 0;
|
||||
if (!installFailed) {
|
||||
return;
|
||||
}
|
||||
if (detectCommand) {
|
||||
const recheck = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
input.target,
|
||||
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: input.timeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
},
|
||||
);
|
||||
if (!recheck.timedOut && recheck.exitCode === 0) {
|
||||
if (input.onLog) {
|
||||
const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`;
|
||||
await input.onLog(
|
||||
"stderr",
|
||||
`[paperclip] Install command ${reason} (${installCommand}) but ${detectCommand} is on PATH; continuing.\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.timedOut) {
|
||||
throw new Error(`Timed out while installing the adapter runtime command via: ${installCommand}`);
|
||||
}
|
||||
throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`);
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetFile(
|
||||
runId: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
@@ -458,15 +738,12 @@ export function adapterExecutionTargetSessionIdentity(
|
||||
): 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,
|
||||
paperclipTransport,
|
||||
...(paperclipTransport === "direct" && target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -485,9 +762,7 @@ export function adapterExecutionTargetSessionMatches(
|
||||
readStringMeta(parsedSaved, "providerKey") === current?.providerKey &&
|
||||
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)
|
||||
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd
|
||||
);
|
||||
}
|
||||
|
||||
@@ -512,14 +787,12 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
|
||||
environmentId: readStringMeta(parsed, "environmentId"),
|
||||
leaseId: readStringMeta(parsed, "leaseId"),
|
||||
remoteCwd: spec.remoteCwd,
|
||||
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null,
|
||||
spec,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
|
||||
const remoteCwd = readStringMeta(parsed, "remoteCwd");
|
||||
const paperclipTransport = readStringMeta(parsed, "paperclipTransport");
|
||||
if (!remoteCwd) return null;
|
||||
return {
|
||||
kind: "remote",
|
||||
@@ -528,11 +801,6 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
|
||||
environmentId: readStringMeta(parsed, "environmentId"),
|
||||
leaseId: readStringMeta(parsed, "leaseId"),
|
||||
remoteCwd,
|
||||
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"),
|
||||
paperclipTransport:
|
||||
paperclipTransport === "direct" || paperclipTransport === "bridge"
|
||||
? paperclipTransport
|
||||
: undefined,
|
||||
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
|
||||
};
|
||||
}
|
||||
@@ -553,7 +821,6 @@ export function adapterExecutionTargetFromRemoteExecution(
|
||||
environmentId: metadata.environmentId ?? null,
|
||||
leaseId: metadata.leaseId ?? null,
|
||||
remoteCwd: ssh.remoteCwd,
|
||||
paperclipApiUrl: ssh.paperclipApiUrl ?? null,
|
||||
spec: ssh,
|
||||
};
|
||||
}
|
||||
@@ -582,6 +849,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
preserveAbsentOnRestore?: string[];
|
||||
assets?: AdapterManagedRuntimeAsset[];
|
||||
installCommand?: string | null;
|
||||
/** When provided alongside `installCommand`, skip the install if the binary is already on PATH. */
|
||||
detectCommand?: string | null;
|
||||
}): Promise<PreparedAdapterExecutionTargetRuntime> {
|
||||
const target = input.target ?? { kind: "local" as const };
|
||||
if (target.kind === "local") {
|
||||
@@ -612,10 +881,10 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
runner: requireSandboxRunner(target),
|
||||
spec: {
|
||||
providerKey: target.providerKey,
|
||||
shellCommand: target.shellCommand,
|
||||
leaseId: target.leaseId,
|
||||
remoteCwd: target.remoteCwd,
|
||||
timeoutMs: target.timeoutMs,
|
||||
paperclipApiUrl: target.paperclipApiUrl,
|
||||
},
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
@@ -623,6 +892,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
installCommand: input.installCommand,
|
||||
detectCommand: input.detectCommand,
|
||||
});
|
||||
return {
|
||||
target,
|
||||
@@ -703,7 +973,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) {
|
||||
return null;
|
||||
}
|
||||
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
|
||||
if (!input.target || input.target.kind !== "remote") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -731,6 +1001,8 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ||
|
||||
process.env.PAPERCLIP_API_URL?.trim() ||
|
||||
resolveDefaultPaperclipApiUrl();
|
||||
const shellCommand = adapterExecutionTargetShellCommand(target);
|
||||
const runner = adapterExecutionTargetCommandRunner(target);
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
@@ -742,15 +1014,30 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null;
|
||||
try {
|
||||
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
|
||||
runner: requireSandboxRunner(target),
|
||||
runner,
|
||||
remoteCwd: target.remoteCwd,
|
||||
timeoutMs: target.timeoutMs,
|
||||
timeoutMs: adapterExecutionTargetTimeoutMs(target),
|
||||
shellCommand,
|
||||
});
|
||||
// PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge
|
||||
// proxy request/response. The query string is logged verbatim, so callers
|
||||
// who pass auth tokens or other sensitive values as query parameters
|
||||
// should be aware those values appear in the host process's stdout when
|
||||
// this flag is enabled. Only intended for active debugging in trusted
|
||||
// environments.
|
||||
const bridgeDebugEnabled = isBridgeDebugEnabled(process.env);
|
||||
worker = await startSandboxCallbackBridgeWorker({
|
||||
client,
|
||||
queueDir,
|
||||
maxBodyBytes,
|
||||
handleRequest: async (request) => {
|
||||
const method = request.method.trim().toUpperCase() || "GET";
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(request.headers)) {
|
||||
if (value.trim().length === 0) continue;
|
||||
@@ -758,13 +1045,18 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
}
|
||||
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),
|
||||
});
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
headers: buildBridgeResponseHeaders(response),
|
||||
@@ -773,14 +1065,15 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
},
|
||||
});
|
||||
server = await startSandboxCallbackBridgeServer({
|
||||
runner: requireSandboxRunner(target),
|
||||
runner,
|
||||
remoteCwd: target.remoteCwd,
|
||||
assetRemoteDir,
|
||||
queueDir,
|
||||
bridgeToken,
|
||||
bridgeAsset,
|
||||
timeoutMs: target.timeoutMs,
|
||||
timeoutMs: adapterExecutionTargetTimeoutMs(target),
|
||||
maxBodyBytes,
|
||||
shellCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
|
||||
@@ -27,6 +27,7 @@ export type {
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
AdapterRuntimeCommandSpec,
|
||||
ServerAdapterModule,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
|
||||
@@ -44,7 +44,6 @@ export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec
|
||||
port: spec.port,
|
||||
username: spec.username,
|
||||
remoteCwd: spec.remoteCwd,
|
||||
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -58,8 +57,7 @@ export function remoteExecutionSessionMatches(saved: unknown, current: SshRemote
|
||||
asString(parsedSaved.host) === currentIdentity.host &&
|
||||
asNumber(parsedSaved.port) === currentIdentity.port &&
|
||||
asString(parsedSaved.username) === currentIdentity.username &&
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd &&
|
||||
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,15 @@ describe("sandbox callback bridge", () => {
|
||||
...process.env,
|
||||
...input.env,
|
||||
};
|
||||
const command = input.command === "sh" ? "/bin/sh" : input.command;
|
||||
const command =
|
||||
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
|
||||
const args = [...(input.args ?? [])];
|
||||
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
|
||||
if (
|
||||
input.stdin != null &&
|
||||
(input.command === "sh" || input.command === "bash") &&
|
||||
args[0] === "-lc" &&
|
||||
typeof args[1] === "string"
|
||||
) {
|
||||
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
||||
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
||||
import { preferredShellForSandbox } from "./sandbox-shell.js";
|
||||
import type { RunProcessResult } from "./server-utils.js";
|
||||
|
||||
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
|
||||
@@ -133,9 +134,10 @@ async function runShell(
|
||||
cwd: string,
|
||||
script: string,
|
||||
timeoutMs: number,
|
||||
shellCommand: "bash" | "sh" = "sh",
|
||||
): Promise<RunProcessResult> {
|
||||
return await runner.execute({
|
||||
command: "sh",
|
||||
command: shellCommand,
|
||||
args: ["-lc", script],
|
||||
cwd,
|
||||
timeoutMs,
|
||||
@@ -266,10 +268,12 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
timeoutMs?: number | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
}): SandboxCallbackBridgeQueueClient {
|
||||
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
||||
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
||||
const runChecked = async (action: string, script: string) =>
|
||||
requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs));
|
||||
requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs, shellCommand));
|
||||
|
||||
return {
|
||||
makeDir: async (remotePath) => {
|
||||
@@ -288,6 +292,7 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
|
||||
"fi",
|
||||
].join("\n"),
|
||||
timeoutMs,
|
||||
shellCommand,
|
||||
);
|
||||
requireSuccessfulResult(`list ${remotePath}`, result);
|
||||
return result.stdout
|
||||
@@ -525,10 +530,12 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||
responseTimeoutMs?: number | null;
|
||||
timeoutMs?: number | null;
|
||||
nodeCommand?: string;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
maxQueueDepth?: number | null;
|
||||
maxBodyBytes?: number | null;
|
||||
}): Promise<StartedSandboxCallbackBridgeServer> {
|
||||
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
|
||||
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
||||
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
|
||||
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
|
||||
if (input.bridgeAsset) {
|
||||
@@ -536,6 +543,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||
runner: input.runner,
|
||||
remoteCwd: input.remoteCwd,
|
||||
timeoutMs,
|
||||
shellCommand,
|
||||
});
|
||||
await assetClient.makeDir(input.assetRemoteDir);
|
||||
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
|
||||
@@ -553,7 +561,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||
});
|
||||
const nodeCommand = input.nodeCommand?.trim() || "node";
|
||||
const startResult = await input.runner.execute({
|
||||
command: "sh",
|
||||
command: shellCommand,
|
||||
args: [
|
||||
"-lc",
|
||||
[
|
||||
@@ -594,6 +602,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||
"exit 1",
|
||||
].join("\n"),
|
||||
timeoutMs,
|
||||
shellCommand,
|
||||
);
|
||||
requireSuccessfulResult("wait for sandbox callback bridge readiness", readyResult);
|
||||
|
||||
@@ -626,7 +635,7 @@ export async function startSandboxCallbackBridgeServer(input: {
|
||||
directories,
|
||||
stop: async () => {
|
||||
const stopResult = await input.runner.execute({
|
||||
command: "sh",
|
||||
command: shellCommand,
|
||||
args: [
|
||||
"-lc",
|
||||
[
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface SandboxRemoteExecutionSpec {
|
||||
remoteCwd: string;
|
||||
timeoutMs: number;
|
||||
apiKey: string | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SandboxManagedRuntimeAsset {
|
||||
@@ -85,7 +84,6 @@ export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteEx
|
||||
remoteCwd,
|
||||
timeoutMs,
|
||||
apiKey: asString(parsed.apiKey).trim() || null,
|
||||
paperclipApiUrl: asString(parsed.paperclipApiUrl).trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +94,6 @@ export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutio
|
||||
provider: spec.provider,
|
||||
sandboxId: spec.sandboxId,
|
||||
remoteCwd: spec.remoteCwd,
|
||||
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -108,8 +105,7 @@ export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxR
|
||||
asString(parsedSaved.transport) === currentIdentity.transport &&
|
||||
asString(parsedSaved.provider) === currentIdentity.provider &&
|
||||
asString(parsedSaved.sandboxId) === currentIdentity.sandboxId &&
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd &&
|
||||
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function preferredShellForSandbox(shellCommand: string | null | undefined): "bash" | "sh" {
|
||||
return shellCommand === "bash" ? "bash" : "sh";
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
renderPaperclipWakePrompt,
|
||||
runningProcesses,
|
||||
runChildProcess,
|
||||
sanitizeSshRemoteEnv,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "./server-utils.js";
|
||||
|
||||
@@ -60,6 +62,86 @@ describe("buildInvocationEnvForLogs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSshRemoteEnv", () => {
|
||||
it("drops inherited host shell identity variables for SSH remote execution", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
NVM_DIR: "/Users/local/.nvm",
|
||||
TMPDIR: "/var/folders/local/T",
|
||||
XDG_CONFIG_HOME: "/Users/local/.config",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
{
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
NVM_DIR: "/Users/local/.nvm",
|
||||
TMPDIR: "/var/folders/local/T",
|
||||
XDG_CONFIG_HOME: "/Users/local/.config",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
SAFE_VALUE: "visible",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit remote overrides even for filtered key names", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
PATH: "/custom/remote/bin:/usr/bin",
|
||||
HOME: "/home/agent",
|
||||
TMPDIR: "/tmp",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
{
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
TMPDIR: "/var/folders/local/T",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
PATH: "/custom/remote/bin:/usr/bin",
|
||||
HOME: "/home/agent",
|
||||
TMPDIR: "/tmp",
|
||||
SAFE_VALUE: "visible",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters identity keys via case-insensitive match against the inherited env", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
// Caller passed PATH in upper case while the inherited (Windows-style)
|
||||
// host env exposes it as Path. The lookup must still treat them as
|
||||
// equal so the leaked host PATH gets stripped.
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/host/home",
|
||||
},
|
||||
{
|
||||
Path: "/host/bin:/usr/bin",
|
||||
home: "/host/home",
|
||||
},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves explicitly-set identity keys when the inherited env disagrees in case but not in value", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
PATH: "/explicit/remote/bin",
|
||||
},
|
||||
{
|
||||
Path: "/host/bin:/usr/bin",
|
||||
},
|
||||
),
|
||||
).toEqual({ PATH: "/explicit/remote/bin" });
|
||||
});
|
||||
});
|
||||
|
||||
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-"));
|
||||
@@ -551,6 +633,70 @@ describe("applyPaperclipWorkspaceEnv", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shapePaperclipWorkspaceEnvForExecution", () => {
|
||||
it("rewrites workspace env paths for remote execution", () => {
|
||||
const shaped = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: "/tmp/workspace",
|
||||
workspaceWorktreePath: "/tmp/worktree",
|
||||
workspaceHints: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/tmp/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: "/tmp/other-workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-3",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
],
|
||||
executionTargetIsRemote: true,
|
||||
executionCwd: "/remote/workspace",
|
||||
});
|
||||
|
||||
expect(shaped).toEqual({
|
||||
workspaceCwd: "/remote/workspace",
|
||||
workspaceWorktreePath: null,
|
||||
workspaceHints: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-3",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves local execution workspace paths unchanged", () => {
|
||||
const workspaceHints = [{ workspaceId: "workspace-1", cwd: "/tmp/workspace" }];
|
||||
const shaped = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: "/tmp/workspace",
|
||||
workspaceWorktreePath: "/tmp/worktree",
|
||||
workspaceHints,
|
||||
executionTargetIsRemote: false,
|
||||
executionCwd: "/remote/workspace",
|
||||
});
|
||||
|
||||
expect(shaped).toEqual({
|
||||
workspaceCwd: "/tmp/workspace",
|
||||
workspaceWorktreePath: "/tmp/worktree",
|
||||
workspaceHints,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendWithByteCap", () => {
|
||||
it("keeps valid UTF-8 when trimming through multibyte text", () => {
|
||||
const output = appendWithByteCap("prefix ", "hello — world", 7);
|
||||
|
||||
@@ -885,6 +885,79 @@ export function applyPaperclipWorkspaceEnv(
|
||||
return env;
|
||||
}
|
||||
|
||||
export function shapePaperclipWorkspaceEnvForExecution(input: {
|
||||
workspaceCwd?: string | null;
|
||||
workspaceWorktreePath?: string | null;
|
||||
workspaceHints?: Array<Record<string, unknown>>;
|
||||
executionTargetIsRemote?: boolean;
|
||||
executionCwd?: string | null;
|
||||
}): {
|
||||
workspaceCwd: string | null;
|
||||
workspaceWorktreePath: string | null;
|
||||
workspaceHints: Array<Record<string, unknown>>;
|
||||
} {
|
||||
const workspaceCwd =
|
||||
typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0
|
||||
? input.workspaceCwd.trim()
|
||||
: null;
|
||||
const workspaceWorktreePath =
|
||||
typeof input.workspaceWorktreePath === "string" && input.workspaceWorktreePath.trim().length > 0
|
||||
? input.workspaceWorktreePath.trim()
|
||||
: null;
|
||||
const workspaceHints = Array.isArray(input.workspaceHints) ? input.workspaceHints : [];
|
||||
|
||||
if (!input.executionTargetIsRemote) {
|
||||
return {
|
||||
workspaceCwd,
|
||||
workspaceWorktreePath,
|
||||
workspaceHints,
|
||||
};
|
||||
}
|
||||
|
||||
const executionCwd =
|
||||
typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0
|
||||
? input.executionCwd.trim()
|
||||
: null;
|
||||
// On a remote target we must never fall back to the local workspaceCwd —
|
||||
// doing so leaks host paths into the remote env (the exact failure mode
|
||||
// this helper exists to prevent). Callers are expected to resolve
|
||||
// executionCwd via adapterExecutionTargetRemoteCwd before calling this
|
||||
// helper, which always returns a non-empty string. Surface a warning so
|
||||
// future callers don't silently regress to the leak.
|
||||
if (executionCwd === null) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[paperclip] shapePaperclipWorkspaceEnvForExecution called with executionCwd=null on a remote target; " +
|
||||
"stripping workspaceCwd to avoid leaking local paths into the remote environment.",
|
||||
);
|
||||
}
|
||||
const realizedWorkspaceCwd = executionCwd;
|
||||
const localWorkspaceCwd = workspaceCwd ? path.resolve(workspaceCwd) : null;
|
||||
const shapedWorkspaceHints = workspaceHints.map((hint) => {
|
||||
const nextHint = { ...hint };
|
||||
const hintCwd = typeof nextHint.cwd === "string" ? nextHint.cwd.trim() : "";
|
||||
if (!hintCwd) return nextHint;
|
||||
|
||||
if (localWorkspaceCwd && path.resolve(hintCwd) === localWorkspaceCwd) {
|
||||
if (realizedWorkspaceCwd) {
|
||||
nextHint.cwd = realizedWorkspaceCwd;
|
||||
} else {
|
||||
delete nextHint.cwd;
|
||||
}
|
||||
return nextHint;
|
||||
}
|
||||
|
||||
delete nextHint.cwd;
|
||||
return nextHint;
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceCwd: realizedWorkspaceCwd,
|
||||
workspaceWorktreePath: null,
|
||||
workspaceHints: shapedWorkspaceHints,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
@@ -966,6 +1039,56 @@ function quoteForCmd(arg: string) {
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
const SSH_REMOTE_ENV_IDENTITY_KEYS = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"PWD",
|
||||
"SHELL",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"NVM_DIR",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
]);
|
||||
|
||||
function readEnvValueCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
||||
const direct = env[key];
|
||||
if (typeof direct === "string") return direct;
|
||||
const upper = key.toUpperCase();
|
||||
for (const [candidateKey, candidateValue] of Object.entries(env)) {
|
||||
if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") {
|
||||
return candidateValue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sanitizeSshRemoteEnv(
|
||||
env: Record<string, string>,
|
||||
inheritedEnv: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const normalizedKey = key.toUpperCase();
|
||||
if (!SSH_REMOTE_ENV_IDENTITY_KEYS.has(normalizedKey)) {
|
||||
sanitized[key] = value;
|
||||
continue;
|
||||
}
|
||||
const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key);
|
||||
if (typeof inheritedValue === "string" && inheritedValue === value) {
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function resolveWindowsCmdShell(env: NodeJS.ProcessEnv): string {
|
||||
const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows";
|
||||
return path.join(fallbackRoot, "System32", "cmd.exe");
|
||||
@@ -991,9 +1114,9 @@ async function resolveSpawnTarget(
|
||||
spec: remote,
|
||||
command,
|
||||
args,
|
||||
env: Object.fromEntries(
|
||||
env: sanitizeSshRemoteEnv(Object.fromEntries(
|
||||
Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
),
|
||||
)),
|
||||
});
|
||||
return {
|
||||
command: sshResolved,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { constants as fsConstants, createReadStream, createWriteStream, promises
|
||||
import net from "node:net";
|
||||
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";
|
||||
|
||||
export interface SshConnectionConfig {
|
||||
host: string;
|
||||
@@ -21,7 +23,86 @@ export interface SshCommandResult {
|
||||
|
||||
export interface SshRemoteExecutionSpec extends SshConnectionConfig {
|
||||
remoteCwd: string;
|
||||
paperclipApiUrl?: string | null;
|
||||
}
|
||||
|
||||
export function createSshCommandManagedRuntimeRunner(input: {
|
||||
spec: SshRemoteExecutionSpec;
|
||||
defaultCwd?: string | null;
|
||||
maxBufferBytes?: number | null;
|
||||
}): CommandManagedRuntimeRunner {
|
||||
const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd;
|
||||
const maxBufferBytes =
|
||||
typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0
|
||||
? Math.trunc(input.maxBufferBytes)
|
||||
: 1024 * 1024;
|
||||
|
||||
return {
|
||||
execute: async (commandInput): Promise<RunProcessResult> => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const command = commandInput.command.trim();
|
||||
const args = commandInput.args ?? [];
|
||||
const cwd = commandInput.cwd?.trim() || defaultCwd;
|
||||
const envEntries = Object.entries(commandInput.env ?? {})
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
|
||||
const envPrefix = envEntries.length > 0
|
||||
? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `
|
||||
: "";
|
||||
const exportPrefix = envEntries.length > 0
|
||||
? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
|
||||
: "";
|
||||
const commandScript = command === "sh" || command === "bash"
|
||||
? args[0] === "-lc" && typeof args[1] === "string"
|
||||
? `${exportPrefix}${args[1]}`
|
||||
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
|
||||
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
|
||||
const remoteCommand = `${command === "bash" ? "bash" : "sh"} -lc ${
|
||||
shellQuote(`cd ${shellQuote(cwd)} && ${commandScript}`)
|
||||
}`;
|
||||
|
||||
try {
|
||||
const result = await runSshCommand(input.spec, remoteCommand, {
|
||||
timeoutMs: commandInput.timeoutMs,
|
||||
maxBuffer: maxBufferBytes,
|
||||
});
|
||||
if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
|
||||
if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
const failure = error as {
|
||||
stdout?: unknown;
|
||||
stderr?: unknown;
|
||||
code?: unknown;
|
||||
signal?: unknown;
|
||||
killed?: unknown;
|
||||
};
|
||||
const stdout = typeof failure.stdout === "string" ? failure.stdout : "";
|
||||
const stderr = typeof failure.stderr === "string"
|
||||
? failure.stderr
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
if (stdout) await commandInput.onLog?.("stdout", stdout);
|
||||
if (stderr) await commandInput.onLog?.("stderr", stderr);
|
||||
return {
|
||||
exitCode: typeof failure.code === "number" ? failure.code : null,
|
||||
signal: typeof failure.signal === "string" ? failure.signal : null,
|
||||
timedOut: failure.killed === true,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshEnvLabSupport {
|
||||
@@ -83,10 +164,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS
|
||||
port: portValue,
|
||||
username,
|
||||
remoteCwd,
|
||||
paperclipApiUrl:
|
||||
typeof parsed.paperclipApiUrl === "string" && parsed.paperclipApiUrl.trim().length > 0
|
||||
? parsed.paperclipApiUrl.trim()
|
||||
: null,
|
||||
remoteWorkspacePath:
|
||||
typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0
|
||||
? parsed.remoteWorkspacePath.trim()
|
||||
@@ -98,50 +175,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHttpUrlCandidate(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findReachablePaperclipApiUrlOverSsh(input: {
|
||||
config: SshConnectionConfig;
|
||||
candidates: string[];
|
||||
timeoutMs?: number;
|
||||
}): Promise<string | null> {
|
||||
const uniqueCandidates = Array.from(
|
||||
new Set(
|
||||
input.candidates
|
||||
.map((candidate) => normalizeHttpUrlCandidate(candidate))
|
||||
.filter((candidate): candidate is string => candidate !== null),
|
||||
),
|
||||
);
|
||||
|
||||
for (const candidate of uniqueCandidates) {
|
||||
const healthUrl = new URL("/api/health", candidate).toString();
|
||||
try {
|
||||
await runSshCommand(
|
||||
input.config,
|
||||
`sh -lc ${shellQuote(`curl -fsS -m ${Math.max(1, Math.ceil((input.timeoutMs ?? 5_000) / 1000))} ${shellQuote(healthUrl)} >/dev/null`)}`,
|
||||
{ timeoutMs: input.timeoutMs ?? 5_000 },
|
||||
);
|
||||
return candidate;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function execFileText(
|
||||
file: string,
|
||||
args: string[],
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface AdapterExecutionContext {
|
||||
runtime: AdapterRuntime;
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
runtimeCommandSpec?: AdapterRuntimeCommandSpec | null;
|
||||
executionTarget?: AdapterExecutionTarget | null;
|
||||
/**
|
||||
* Legacy remote transport view. Prefer `executionTarget`, which is the
|
||||
@@ -328,6 +329,23 @@ export interface AdapterConfigSchema {
|
||||
fields: ConfigFieldSchema[];
|
||||
}
|
||||
|
||||
export interface AdapterRuntimeCommandSpec {
|
||||
/**
|
||||
* The command Paperclip should execute for this adapter in the current config.
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* Optional command name/path to probe for availability before launch.
|
||||
* Defaults to `command` when omitted by the consumer.
|
||||
*/
|
||||
detectCommand?: string | null;
|
||||
/**
|
||||
* Optional shell snippet that can install or expose the adapter command in a
|
||||
* fresh remote runtime. It should be idempotent.
|
||||
*/
|
||||
installCommand?: string | null;
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
@@ -406,6 +424,11 @@ export interface ServerAdapterModule {
|
||||
* rather than reading config.paperclipRuntimeSkills.
|
||||
*/
|
||||
requiresMaterializedRuntimeSkills?: boolean;
|
||||
/**
|
||||
* Optional: describe how this adapter's runtime command should be launched
|
||||
* and provisioned in fresh remote environments such as sandboxes.
|
||||
*/
|
||||
getRuntimeCommandSpec?: (config: Record<string, unknown>) => AdapterRuntimeCommandSpec | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -56,7 +56,13 @@ function buildRuntime() {
|
||||
};
|
||||
}
|
||||
|
||||
async function runExecutor(config: Record<string, unknown>) {
|
||||
async function runExecutor(
|
||||
config: Record<string, unknown>,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
executionTransport?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const runtimeOptions: Record<string, unknown>[] = [];
|
||||
const meta: Record<string, unknown>[] = [];
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
@@ -73,12 +79,13 @@ async function runExecutor(config: Record<string, unknown>) {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
},
|
||||
runtime: {},
|
||||
config,
|
||||
context: {},
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
runtime: {},
|
||||
config,
|
||||
context: options.context ?? {},
|
||||
executionTransport: options.executionTransport,
|
||||
onLog: async (stream: "stdout" | "stderr", text: string) => {
|
||||
logs.push({ stream, text });
|
||||
},
|
||||
onMeta: async (payload: unknown) => {
|
||||
meta.push(payload as Record<string, unknown>);
|
||||
},
|
||||
@@ -257,6 +264,57 @@ describe("acpx_local runtime skill isolation", () => {
|
||||
expect(env).not.toContain("old-key");
|
||||
});
|
||||
|
||||
it("shapes ACPX wrapper workspace env for remote execution identities", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
await runExecutor(
|
||||
{
|
||||
agentCommand: "node ./fake-acp.js",
|
||||
stateDir,
|
||||
},
|
||||
{
|
||||
context: {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
strategy: "git_worktree",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
branchName: "feature/remote-acpx",
|
||||
worktreePath: workspaceDir,
|
||||
},
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
remoteCwd: "/remote/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
|
||||
const envPath = path.join(
|
||||
stateDir,
|
||||
"wrappers",
|
||||
wrappers.find((name) => name.endsWith(".env"))!,
|
||||
);
|
||||
const env = await fs.readFile(envPath, "utf8");
|
||||
|
||||
expect(env).toContain("PAPERCLIP_WORKSPACE_CWD='/remote/workspace'");
|
||||
expect(env).not.toContain("PAPERCLIP_WORKSPACE_WORKTREE_PATH=");
|
||||
});
|
||||
|
||||
it("cleans aged credential wrapper scripts across ACPX agent changes", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
renderPaperclipWakePrompt,
|
||||
renderTemplate,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
type PaperclipSkillEntry,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -622,6 +623,21 @@ async function buildRuntime(input: {
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const executionTarget = readAdapterExecutionTarget({
|
||||
executionTarget: input.ctx.executionTarget,
|
||||
legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution,
|
||||
});
|
||||
const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget);
|
||||
const effectiveExecutionCwd =
|
||||
remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string"
|
||||
? remoteExecutionIdentity.remoteCwd
|
||||
: cwd;
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceWorktreePath,
|
||||
executionTargetIsRemote: remoteExecutionIdentity !== null,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const acpxAgent = normalizeAgent(config);
|
||||
@@ -659,14 +675,14 @@ async function buildRuntime(input: {
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceStrategy,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceBranch,
|
||||
workspaceWorktreePath,
|
||||
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
|
||||
agentHome,
|
||||
});
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
@@ -718,11 +734,6 @@ async function buildRuntime(input: {
|
||||
const wrapperPath = wrapper?.wrapperPath ?? null;
|
||||
const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined;
|
||||
const agentRegistry = createAgentRegistry({ overrides });
|
||||
const executionTarget = readAdapterExecutionTarget({
|
||||
executionTarget: input.ctx.executionTarget,
|
||||
legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution,
|
||||
});
|
||||
const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget);
|
||||
const fingerprint = shortHash({
|
||||
acpxAgent,
|
||||
agentCommand: agentCommand ?? acpxAgent,
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
export const type = "claude_local";
|
||||
export const label = "Claude Code (local)";
|
||||
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
|
||||
|
||||
export const models = [
|
||||
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
prepareWorkspaceForSshExecution,
|
||||
restoreWorkspaceFromSshExecution,
|
||||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
@@ -29,6 +30,14 @@ const {
|
||||
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
|
||||
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
|
||||
syncDirectoryToSsh: vi.fn(async () => undefined),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
||||
env: {
|
||||
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
||||
PAPERCLIP_API_KEY: "bridge-token",
|
||||
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
||||
},
|
||||
stop: async () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
@@ -55,6 +64,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
describe("claude remote execution", () => {
|
||||
@@ -73,8 +92,10 @@ describe("claude remote execution", () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
|
||||
const instructionsPath = path.join(rootDir, "instructions.md");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||
await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8");
|
||||
|
||||
await execute({
|
||||
@@ -100,7 +121,27 @@ describe("claude remote execution", () => {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
strategy: "git_worktree",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
branchName: "feature/remote-claude",
|
||||
worktreePath: workspaceDir,
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: workspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: alternateWorkspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
],
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
@@ -112,7 +153,6 @@ describe("claude remote execution", () => {
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
@@ -136,8 +176,25 @@ describe("claude remote execution", () => {
|
||||
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md");
|
||||
expect(call?.[2]).toContain("--add-dir");
|
||||
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills");
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined();
|
||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
]);
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
|
||||
localDir: workspaceDir,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip
|
||||
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetPaperclipApiUrl,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -53,6 +54,7 @@ import { prepareClaudeConfigSeed } from "./claude-config.js";
|
||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { prepareClaudePromptBundle } from "./prompt-cache.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -61,8 +63,10 @@ interface ClaudeExecutionInput {
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
runtimeCommandSpec?: AdapterExecutionContext["runtimeCommandSpec"];
|
||||
executionTarget?: ReturnType<typeof readAdapterExecutionTarget>;
|
||||
authToken?: string;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface ClaudeRuntimeConfig {
|
||||
@@ -112,7 +116,8 @@ function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscri
|
||||
}
|
||||
|
||||
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
||||
const { runId, agent, config, context, executionTarget, authToken } = input;
|
||||
const { runId, agent, config, context, runtimeCommandSpec, executionTarget, authToken } = input;
|
||||
const onLog = input.onLog ?? (async () => {});
|
||||
|
||||
const command = asString(config.command, "claude");
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
@@ -145,6 +150,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceWorktreePath,
|
||||
workspaceHints,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
@@ -200,18 +214,18 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceStrategy,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceBranch,
|
||||
workspaceWorktreePath,
|
||||
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
@@ -222,11 +236,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) {
|
||||
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
@@ -235,8 +244,25 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: runtimeCommandSpec?.installCommand,
|
||||
detectCommand: runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
@@ -244,8 +270,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
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;
|
||||
@@ -331,8 +355,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agent,
|
||||
config,
|
||||
context,
|
||||
runtimeCommandSpec: ctx.runtimeCommandSpec,
|
||||
executionTarget,
|
||||
authToken,
|
||||
onLog,
|
||||
});
|
||||
const {
|
||||
command,
|
||||
@@ -405,6 +431,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
target: executionTarget,
|
||||
adapterKey: "claude",
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [
|
||||
{
|
||||
key: "skills",
|
||||
@@ -777,6 +805,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const transientUpstream =
|
||||
failed &&
|
||||
!loginMeta.requiresLogin &&
|
||||
!clearSessionForMaxTurns &&
|
||||
isClaudeTransientUpstreamError({
|
||||
parsed,
|
||||
stdout: proc.stdout,
|
||||
@@ -793,11 +822,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
: null;
|
||||
const resolvedErrorCode = loginMeta.requiresLogin
|
||||
? "claude_auth_required"
|
||||
: failed && clearSessionForMaxTurns
|
||||
? "max_turns_exhausted"
|
||||
: transientUpstream
|
||||
? "claude_transient_upstream"
|
||||
: null;
|
||||
const mergedResultJson: Record<string, unknown> = {
|
||||
...parsed,
|
||||
...(failed && clearSessionForMaxTurns ? { stopReason: "max_turns_exhausted" } : {}),
|
||||
...(transientUpstream ? { errorFamily: "transient_upstream" } : {}),
|
||||
...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
|
||||
@@ -170,11 +170,19 @@ export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null |
|
||||
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error_max_turns") return true;
|
||||
|
||||
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
|
||||
if (stopReason === "max_turns") return true;
|
||||
const structuredStopReasons = [
|
||||
parsed.stop_reason,
|
||||
parsed.stopReason,
|
||||
parsed.error_code,
|
||||
parsed.errorCode,
|
||||
].map((value) => asString(value, "").trim().toLowerCase());
|
||||
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
return /max(?:imum)?\s+turns?/i.test(resultText);
|
||||
return structuredStopReasons.some((reason) =>
|
||||
reason === "max_turns" ||
|
||||
reason === "max_turns_exhausted" ||
|
||||
reason === "turn_limit" ||
|
||||
reason === "turn_limit_exhausted",
|
||||
);
|
||||
}
|
||||
|
||||
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
maybeRunSandboxInstallCommand,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import path from "node:path";
|
||||
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
@@ -102,6 +104,15 @@ export async function testEnvironment(
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "claude",
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
export const type = "codex_local";
|
||||
export const label = "Codex (local)";
|
||||
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex";
|
||||
|
||||
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;
|
||||
|
||||
@@ -71,33 +71,71 @@ async function ensureCopiedFile(target: string, source: string): Promise<void> {
|
||||
await fs.copyFile(source, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an `auth.json` containing only `OPENAI_API_KEY` so the codex CLI can
|
||||
* authenticate via API key. Overwrites any existing file or symlink at that
|
||||
* path. Required because the codex CLI (>= 0.122) ignores the `OPENAI_API_KEY`
|
||||
* environment variable and only reads credentials from `$CODEX_HOME/auth.json`.
|
||||
*/
|
||||
export async function writeApiKeyAuthJson(home: string, apiKey: string): Promise<void> {
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
const target = path.join(home, "auth.json");
|
||||
await fs.rm(target, { force: true });
|
||||
await fs.writeFile(target, JSON.stringify({ OPENAI_API_KEY: apiKey }), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export async function prepareManagedCodexHome(
|
||||
env: NodeJS.ProcessEnv,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
companyId?: string,
|
||||
options: { apiKey?: string | null } = {},
|
||||
): Promise<string> {
|
||||
const targetHome = resolveManagedCodexHomeDir(env, companyId);
|
||||
const apiKey = nonEmpty(options.apiKey ?? undefined);
|
||||
|
||||
const sourceHome = resolveSharedCodexHomeDir(env);
|
||||
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
|
||||
const seedFromShared = path.resolve(sourceHome) !== path.resolve(targetHome);
|
||||
|
||||
await fs.mkdir(targetHome, { recursive: true });
|
||||
|
||||
for (const name of SYMLINKED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureSymlink(path.join(targetHome, name), source);
|
||||
// If a previous run wrote an apikey-mode auth.json (regular file) and this
|
||||
// run has no apiKey, remove it so the chatgpt-mode symlink can be restored.
|
||||
// Without this cleanup, ensureSymlink bails on a non-symlink and Codex keeps
|
||||
// authenticating with the stale key after it is removed from configuration.
|
||||
if (!apiKey && seedFromShared) {
|
||||
const authPath = path.join(targetHome, "auth.json");
|
||||
const existing = await fs.lstat(authPath).catch(() => null);
|
||||
if (existing && !existing.isSymbolicLink()) {
|
||||
await fs.rm(authPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of COPIED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureCopiedFile(path.join(targetHome, name), source);
|
||||
if (seedFromShared) {
|
||||
for (const name of SYMLINKED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureSymlink(path.join(targetHome, name), source);
|
||||
}
|
||||
|
||||
for (const name of COPIED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureCopiedFile(path.join(targetHome, name), source);
|
||||
}
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
await writeApiKeyAuthJson(targetHome, apiKey);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
|
||||
);
|
||||
return targetHome;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
prepareWorkspaceForSshExecution,
|
||||
restoreWorkspaceFromSshExecution,
|
||||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
@@ -25,6 +26,14 @@ const {
|
||||
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
|
||||
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
|
||||
syncDirectoryToSsh: vi.fn(async () => undefined),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
||||
env: {
|
||||
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
||||
PAPERCLIP_API_KEY: "bridge-token",
|
||||
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
||||
},
|
||||
stop: async () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
@@ -51,6 +60,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
describe("codex remote execution", () => {
|
||||
@@ -74,6 +93,8 @@ describe("codex remote execution", () => {
|
||||
await mkdir(codexHomeDir, { recursive: true });
|
||||
await writeFile(path.join(rootDir, "instructions.md"), "Use the remote workspace.\n", "utf8");
|
||||
await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8");
|
||||
const alternateWorkspaceDir = path.join(rootDir, "alternate-workspace");
|
||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||
|
||||
await execute({
|
||||
runId: "run-1",
|
||||
@@ -100,7 +121,27 @@ describe("codex remote execution", () => {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
strategy: "git_worktree",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
branchName: "feature/remote-codex",
|
||||
worktreePath: workspaceDir,
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: workspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: alternateWorkspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
],
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
@@ -134,7 +175,25 @@ describe("codex remote execution", () => {
|
||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||
| undefined;
|
||||
expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined();
|
||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
]);
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
|
||||
localDir: workspaceDir,
|
||||
|
||||
@@ -4,13 +4,13 @@ import { fileURLToPath } from "node:url";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetPaperclipApiUrl,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
|
||||
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||
import { buildCodexExecArgs } from "./codex-args.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
@@ -331,8 +333,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const configuredOpenAiApiKey =
|
||||
typeof envConfig.OPENAI_API_KEY === "string" && envConfig.OPENAI_API_KEY.trim().length > 0
|
||||
? envConfig.OPENAI_API_KEY.trim()
|
||||
: null;
|
||||
const preparedManagedCodexHome =
|
||||
configuredCodexHome ? null : await prepareManagedCodexHome(process.env, onLog, agent.companyId);
|
||||
configuredCodexHome
|
||||
? null
|
||||
: await prepareManagedCodexHome(process.env, onLog, agent.companyId, {
|
||||
apiKey: configuredOpenAiApiKey,
|
||||
});
|
||||
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
|
||||
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
|
||||
await fs.mkdir(effectiveCodexHome, { recursive: true });
|
||||
@@ -348,6 +358,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
},
|
||||
);
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceWorktreePath,
|
||||
workspaceHints,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
const preparedExecutionTargetRuntime = executionTargetIsRemote
|
||||
? await (async () => {
|
||||
await onLog(
|
||||
@@ -358,6 +375,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
target: executionTarget,
|
||||
adapterKey: "codex",
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [
|
||||
{
|
||||
key: "home",
|
||||
@@ -426,18 +445,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceStrategy,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceBranch,
|
||||
workspaceWorktreePath,
|
||||
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
@@ -448,10 +467,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) {
|
||||
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
@@ -478,7 +493,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
),
|
||||
);
|
||||
const billingType = resolveCodexBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv(effectiveEnv)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: asNumber(config.timeoutSec, 0),
|
||||
graceSec: asNumber(config.graceSec, 20),
|
||||
onLog,
|
||||
});
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
maybeRunSandboxInstallCommand,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { parseCodexJsonl } from "./parse.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
|
||||
import { buildCodexExecArgs } from "./codex-args.js";
|
||||
|
||||
@@ -103,6 +106,15 @@ export async function testEnvironment(
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "codex",
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
@@ -174,14 +186,45 @@ export async function testEnvironment(
|
||||
});
|
||||
}
|
||||
|
||||
// Codex CLI (>= 0.122) ignores the OPENAI_API_KEY env var and only reads
|
||||
// credentials from $CODEX_HOME/auth.json. When we have a key available,
|
||||
// wrap the probe with a shell that materializes a per-run auth.json so
|
||||
// the CLI can authenticate. The key content is passed via env (not on
|
||||
// the command line) to avoid leaking it into process listings.
|
||||
const probeApiKey = isNonEmpty(configOpenAiKey)
|
||||
? configOpenAiKey
|
||||
: isNonEmpty(hostOpenAiKey)
|
||||
? hostOpenAiKey
|
||||
: null;
|
||||
let probeCommand = command;
|
||||
let probeArgs = args;
|
||||
const probeEnv: Record<string, string> = { ...env };
|
||||
if (probeApiKey) {
|
||||
const probeHome = targetIsRemote
|
||||
? `/tmp/paperclip-codex-probe-${runId}`
|
||||
: path.join(os.tmpdir(), `paperclip-codex-probe-${runId}`);
|
||||
probeEnv.CODEX_HOME = probeHome;
|
||||
probeEnv._PAPERCLIP_CODEX_AUTH_JSON = JSON.stringify({ OPENAI_API_KEY: probeApiKey });
|
||||
probeCommand = "sh";
|
||||
// Trap on EXIT removes the probe home (with the API-key auth.json) on
|
||||
// any exit path; we drop `exec` so the wrapper shell stays alive long
|
||||
// enough for the trap to fire after the child returns.
|
||||
probeArgs = [
|
||||
"-c",
|
||||
'set -e; mkdir -p "$CODEX_HOME"; umask 077; printf "%s" "$_PAPERCLIP_CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"; unset _PAPERCLIP_CODEX_AUTH_JSON; trap \'rm -rf "$CODEX_HOME"\' EXIT INT TERM; "$0" "$@"',
|
||||
command,
|
||||
...args,
|
||||
];
|
||||
}
|
||||
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
args,
|
||||
probeCommand,
|
||||
probeArgs,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
env: probeEnv,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
@@ -221,7 +264,9 @@ export async function testEnvironment(
|
||||
level: "warn",
|
||||
message: "Codex CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Configure OPENAI_API_KEY in adapter env/shell or run `codex login`, then retry the probe.",
|
||||
hint: probeApiKey
|
||||
? "OPENAI_API_KEY was provided but Codex still rejected the request. Verify the key is valid for the OpenAI Responses API (e.g. `curl -H \"Authorization: Bearer $OPENAI_API_KEY\" https://api.openai.com/v1/models`), or run `codex login` and seed `~/.codex/auth.json`."
|
||||
: "Codex CLI does not read OPENAI_API_KEY from the environment; set OPENAI_API_KEY in this adapter's config (so Paperclip writes it to `$CODEX_HOME/auth.json`) or run `codex login` on the host first.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
|
||||
@@ -3,6 +3,15 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
export const type = "cursor";
|
||||
export const label = "Cursor CLI (local)";
|
||||
|
||||
// Cursor CLI is not distributed as an npm package — the official install
|
||||
// path is the upstream installer script at cursor.com/install. Other adapters
|
||||
// in this repo prefer `npm install -g <pkg>` which is content-addressed by the
|
||||
// registry; cursor must use `curl | bash` until upstream publishes a registry
|
||||
// artifact. Pinning a commit/version here would require shipping our own
|
||||
// mirror of the installer; revisit if Cursor adds an npm/release-asset
|
||||
// equivalent.
|
||||
export const SANDBOX_INSTALL_COMMAND = "curl https://cursor.com/install -fsS | bash";
|
||||
|
||||
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
|
||||
|
||||
const CURSOR_FALLBACK_MODEL_IDS = [
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
restoreWorkspaceFromSshExecution,
|
||||
runSshCommand,
|
||||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
@@ -35,6 +36,14 @@ const {
|
||||
exitCode: 0,
|
||||
})),
|
||||
syncDirectoryToSsh: vi.fn(async () => undefined),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
||||
env: {
|
||||
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
||||
PAPERCLIP_API_KEY: "bridge-token",
|
||||
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
||||
},
|
||||
stop: async () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
@@ -62,6 +71,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
describe("cursor remote execution", () => {
|
||||
@@ -80,7 +99,9 @@ describe("cursor remote execution", () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
@@ -105,6 +126,20 @@ describe("cursor remote execution", () => {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: workspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: alternateWorkspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
],
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
@@ -116,7 +151,6 @@ describe("cursor remote execution", () => {
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
@@ -131,7 +165,6 @@ describe("cursor remote execution", () => {
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
});
|
||||
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
||||
@@ -150,8 +183,24 @@ describe("cursor remote execution", () => {
|
||||
| undefined;
|
||||
expect(call?.[2]).toContain("--workspace");
|
||||
expect(call?.[2]).toContain("/remote/workspace");
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
]);
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetPaperclipApiUrl,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
readAdapterExecutionTargetHomeDir,
|
||||
@@ -37,11 +37,12 @@ import {
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import { prepareCursorSandboxCommand } from "./remote-command.js";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
@@ -222,6 +223,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceHints,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||
@@ -282,19 +290,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) {
|
||||
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
@@ -304,6 +308,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
// 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.
|
||||
@@ -325,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
const billingType = resolveCursorBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
let loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
@@ -339,7 +354,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
|
||||
let localSkillsDir: string | null = null;
|
||||
let remoteRuntimeRootDir: string | null = null;
|
||||
@@ -356,6 +370,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
target: executionTarget,
|
||||
adapterKey: "cursor",
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [{
|
||||
key: "skills",
|
||||
localDir: localSkillsDir,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
maybeRunSandboxInstallCommand,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
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 { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { parseCursorJsonl } from "./parse.js";
|
||||
import { isDefaultCursorCommand, prepareCursorSandboxCommand } from "./remote-command.js";
|
||||
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
||||
@@ -148,6 +149,15 @@ export async function testEnvironment(
|
||||
command = sandboxCommand.command;
|
||||
env = sandboxCommand.env;
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "cursor",
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
|
||||
@@ -93,14 +93,14 @@ function printTextMessage(prefix: string, colorize: (text: string) => string, me
|
||||
}
|
||||
|
||||
function printUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata) ?? asRecord(parsed.stats);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
|
||||
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
|
||||
const cached = asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, asNumber(source.cached))),
|
||||
);
|
||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
@@ -154,6 +154,21 @@ export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gemini CLI v0.38+ stream-json schema:
|
||||
// {"type":"message","role":"assistant"|"user","content":"...","delta":?true}
|
||||
if (type === "message") {
|
||||
const role = asString(parsed.role).trim().toLowerCase();
|
||||
if (role === "assistant") {
|
||||
printTextMessage("assistant", pc.green, parsed.content);
|
||||
return;
|
||||
}
|
||||
if (role === "user") {
|
||||
printTextMessage("user", pc.gray, parsed.content);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
@@ -190,11 +205,17 @@ export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
|
||||
|
||||
if (type === "result") {
|
||||
printUsage(parsed);
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const isError = parsed.is_error === true;
|
||||
const status = asString(parsed.status).toLowerCase();
|
||||
const isError =
|
||||
parsed.is_error === true || status === "error" || status === "failed";
|
||||
const subtype = asString(parsed.subtype, status || "result");
|
||||
if (subtype || isError) {
|
||||
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
|
||||
}
|
||||
if (isError) {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.result);
|
||||
if (text) console.log(pc.red(`error: ${text}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
export const type = "gemini_local";
|
||||
export const label = "Gemini CLI (local)";
|
||||
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @google/gemini-cli";
|
||||
|
||||
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
||||
|
||||
export const models = [
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
restoreWorkspaceFromSshExecution,
|
||||
runSshCommand,
|
||||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
@@ -18,13 +19,12 @@ const {
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }),
|
||||
JSON.stringify({ type: "message", role: "assistant", content: "hello" }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
status: "success",
|
||||
session_id: "gemini-session-1",
|
||||
usage: { promptTokenCount: 1, cachedContentTokenCount: 0, candidatesTokenCount: 1 },
|
||||
result: "hello",
|
||||
stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
|
||||
}),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
@@ -41,6 +41,14 @@ const {
|
||||
exitCode: 0,
|
||||
})),
|
||||
syncDirectoryToSsh: vi.fn(async () => undefined),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
||||
env: {
|
||||
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
||||
PAPERCLIP_API_KEY: "bridge-token",
|
||||
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
||||
},
|
||||
stop: async () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
@@ -68,6 +76,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
describe("gemini remote execution", () => {
|
||||
@@ -86,7 +104,9 @@ describe("gemini remote execution", () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
@@ -111,6 +131,20 @@ describe("gemini remote execution", () => {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: workspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: alternateWorkspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
],
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
@@ -122,7 +156,6 @@ describe("gemini remote execution", () => {
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
@@ -137,7 +170,6 @@ describe("gemini remote execution", () => {
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
});
|
||||
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
||||
@@ -154,8 +186,24 @@ describe("gemini remote execution", () => {
|
||||
const call = runChildProcess.mock.calls[0] as unknown as
|
||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||
| undefined;
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
]);
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetPaperclipApiUrl,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
readAdapterExecutionTargetHomeDir,
|
||||
@@ -40,11 +40,12 @@ import {
|
||||
parseObject,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import {
|
||||
describeGeminiFailure,
|
||||
detectGeminiAuthRequired,
|
||||
@@ -200,6 +201,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceHints,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
|
||||
@@ -244,17 +252,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
@@ -267,8 +274,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
),
|
||||
);
|
||||
const billingType = resolveGeminiBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv(effectiveEnv)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
let loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
@@ -276,14 +300,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
|
||||
let remoteSkillsDir: string | null = null;
|
||||
let localSkillsDir: string | null = null;
|
||||
@@ -301,6 +322,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
target: executionTarget,
|
||||
adapterKey: "gemini",
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [{
|
||||
key: "skills",
|
||||
localDir: localSkillsDir,
|
||||
@@ -531,7 +554,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
}
|
||||
|
||||
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const structuredFailure = attempt.parsed.resultEvent
|
||||
? describeGeminiFailure(attempt.parsed.resultEvent)
|
||||
: null;
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
structuredFailure ||
|
||||
stderrLine ||
|
||||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
const failed = (attempt.proc.exitCode ?? 0) !== 0;
|
||||
const clearSessionForTurnLimit = isGeminiTurnLimitResult(
|
||||
attempt.parsed.resultEvent,
|
||||
attempt.proc.exitCode,
|
||||
);
|
||||
|
||||
// On retry, don't fall back to old session ID — the old session was stale
|
||||
const canFallbackToRuntimeSession = !isRetry;
|
||||
@@ -551,23 +588,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
: {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const structuredFailure = attempt.parsed.resultEvent
|
||||
? describeGeminiFailure(attempt.parsed.resultEvent)
|
||||
: null;
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
structuredFailure ||
|
||||
stderrLine ||
|
||||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
const resultJson: Record<string, unknown> = {
|
||||
...(attempt.parsed.resultEvent ?? {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
}),
|
||||
...(failed && clearSessionForTurnLimit ? { stopReason: "max_turns_exhausted" } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||
errorMessage: failed ? fallbackErrorMessage : null,
|
||||
errorCode: failed && authMeta.requiresAuth
|
||||
? "gemini_auth_required"
|
||||
: failed && clearSessionForTurnLimit
|
||||
? "max_turns_exhausted"
|
||||
: null,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
@@ -577,10 +615,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: attempt.parsed.resultEvent ?? {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
resultJson,
|
||||
summary: attempt.parsed.summary,
|
||||
question: attempt.parsed.question,
|
||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseGeminiJsonl } from "./parse.js";
|
||||
|
||||
describe("parseGeminiJsonl", () => {
|
||||
it("collects assistant text from message events with string content", () => {
|
||||
const stdout = [
|
||||
'{"type":"init","session_id":"session-1"}',
|
||||
'{"type":"message","role":"user","content":"Respond with hello."}',
|
||||
'{"type":"message","role":"assistant","content":"hello","delta":true}',
|
||||
'{"type":"result","status":"success"}',
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
|
||||
expect(parsed.sessionId).toBe("session-1");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("collects assistant text from message events with structured object content", () => {
|
||||
const stdout = [
|
||||
'{"type":"init","session_id":"session-2"}',
|
||||
'{"type":"message","role":"assistant","content":{"content":[{"type":"text","text":"first part"},{"type":"text","text":"second part"}]}}',
|
||||
'{"type":"result","status":"success"}',
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
|
||||
expect(parsed.sessionId).toBe("session-2");
|
||||
expect(parsed.summary).toBe("first part\n\nsecond part");
|
||||
expect(parsed.errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores non-assistant message events", () => {
|
||||
const stdout = [
|
||||
'{"type":"message","role":"user","content":"hidden user input"}',
|
||||
'{"type":"message","role":"system","content":"hidden system note"}',
|
||||
'{"type":"message","role":"assistant","content":"visible response"}',
|
||||
'{"type":"result","status":"success"}',
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
|
||||
expect(parsed.summary).toBe("visible response");
|
||||
});
|
||||
|
||||
it("captures assistant text from gemini CLI v0.38 stream-json schema", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "init",
|
||||
timestamp: "2026-05-04T05:43:41.203Z",
|
||||
session_id: "session-abc",
|
||||
model: "auto-gemini-3",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
timestamp: "2026-05-04T05:43:41.205Z",
|
||||
role: "user",
|
||||
content: "Respond with hello.",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
timestamp: "2026-05-04T05:43:45.198Z",
|
||||
role: "assistant",
|
||||
content: "hello.",
|
||||
delta: true,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
timestamp: "2026-05-04T05:43:45.819Z",
|
||||
status: "success",
|
||||
stats: {
|
||||
total_tokens: 9468,
|
||||
input_tokens: 9095,
|
||||
output_tokens: 29,
|
||||
cached: 8132,
|
||||
duration_ms: 4616,
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.summary).toBe("hello.");
|
||||
expect(result.sessionId).toBe("session-abc");
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(result.usage.inputTokens).toBe(9095);
|
||||
expect(result.usage.outputTokens).toBe(29);
|
||||
expect(result.usage.cachedInputTokens).toBe(8132);
|
||||
});
|
||||
|
||||
it("ignores user messages and only collects assistant content", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "message", role: "user", content: "ignore me" }),
|
||||
JSON.stringify({ type: "message", role: "assistant", content: "first" }),
|
||||
JSON.stringify({ type: "message", role: "assistant", content: "second" }),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.summary).toBe("first\n\nsecond");
|
||||
});
|
||||
|
||||
it("preserves the legacy claude-style `assistant` event handler", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "legacy-session",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "legacy hello" }] },
|
||||
}),
|
||||
JSON.stringify({ type: "result", subtype: "success", result: "legacy hello" }),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.summary).toBe("legacy hello");
|
||||
expect(result.sessionId).toBe("legacy-session");
|
||||
});
|
||||
|
||||
it("flags result events with status=error", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
status: "error",
|
||||
error: "boom",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.errorMessage).toBe("boom");
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,10 @@ function accumulateUsage(
|
||||
);
|
||||
target.cachedInputTokens += asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
||||
asNumber(
|
||||
source.cachedInputTokens,
|
||||
asNumber(source.cachedContentTokenCount, asNumber(source.cached, 0)),
|
||||
),
|
||||
);
|
||||
target.outputTokens += asNumber(
|
||||
source.output_tokens,
|
||||
@@ -121,16 +124,34 @@ export function parseGeminiJsonl(stdout: string) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gemini CLI v0.38+ stream-json schema emits assistant turns as:
|
||||
// {"type":"message","role":"assistant","content":"...","delta":true}
|
||||
// These are discrete final messages (one per assistant turn), not
|
||||
// cumulative streaming tokens, so collecting all of them produces the
|
||||
// expected concatenated turn-by-turn summary rather than duplicated text.
|
||||
if (type === "message") {
|
||||
const role = asString(event.role, "").trim().toLowerCase();
|
||||
if (role === "assistant") {
|
||||
messages.push(...collectMessageText(event.content));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
resultEvent = event;
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata ?? event.stats);
|
||||
const resultText =
|
||||
asString(event.result, "").trim() ||
|
||||
asString(event.text, "").trim() ||
|
||||
asString(event.response, "").trim();
|
||||
if (resultText && messages.length === 0) messages.push(resultText);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||
const status = asString(event.status, "").toLowerCase();
|
||||
const isError =
|
||||
event.is_error === true ||
|
||||
asString(event.subtype, "").toLowerCase() === "error" ||
|
||||
status === "error" ||
|
||||
status === "failed";
|
||||
if (isError) {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||
if (text) errorMessage = text;
|
||||
@@ -273,9 +294,18 @@ export function isGeminiTurnLimitResult(
|
||||
if (exitCode === 53) return true;
|
||||
if (!parsed) return false;
|
||||
|
||||
const status = asString(parsed.status, "").trim().toLowerCase();
|
||||
if (status === "turn_limit" || status === "max_turns") return true;
|
||||
const structuredStopReasons = [
|
||||
parsed.status,
|
||||
parsed.stopReason,
|
||||
parsed.stop_reason,
|
||||
parsed.errorCode,
|
||||
parsed.error_code,
|
||||
].map((value) => asString(value, "").trim().toLowerCase());
|
||||
|
||||
const error = asString(parsed.error, "").trim();
|
||||
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
|
||||
return structuredStopReasons.some((reason) =>
|
||||
reason === "turn_limit" ||
|
||||
reason === "max_turns" ||
|
||||
reason === "max_turns_exhausted" ||
|
||||
reason === "turn_limit_exhausted",
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
maybeRunSandboxInstallCommand,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
|
||||
import { firstNonEmptyLine } from "./utils.js";
|
||||
|
||||
@@ -94,6 +95,15 @@ export async function testEnvironment(
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "gemini",
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseGeminiStdoutLine } from "./parse-stdout.js";
|
||||
|
||||
const ts = "2026-05-04T05:43:45.198Z";
|
||||
|
||||
describe("parseGeminiStdoutLine", () => {
|
||||
it("renders v0.38 message+role:assistant as an assistant transcript entry", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "hello.",
|
||||
delta: true,
|
||||
});
|
||||
const entries = parseGeminiStdoutLine(line, ts);
|
||||
expect(entries).toEqual([{ kind: "assistant", ts, text: "hello." }]);
|
||||
});
|
||||
|
||||
it("renders v0.38 message+role:user as a user transcript entry", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "Respond with hello.",
|
||||
});
|
||||
const entries = parseGeminiStdoutLine(line, ts);
|
||||
expect(entries).toEqual([{ kind: "user", ts, text: "Respond with hello." }]);
|
||||
});
|
||||
|
||||
it("preserves the legacy claude-style assistant event handler", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "legacy hello" }] },
|
||||
});
|
||||
const entries = parseGeminiStdoutLine(line, ts);
|
||||
expect(entries).toEqual([{ kind: "assistant", ts, text: "legacy hello" }]);
|
||||
});
|
||||
|
||||
it("reads token usage from v0.38 result.stats", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "result",
|
||||
status: "success",
|
||||
stats: {
|
||||
total_tokens: 9468,
|
||||
input_tokens: 9095,
|
||||
output_tokens: 29,
|
||||
cached: 8132,
|
||||
},
|
||||
});
|
||||
const [entry] = parseGeminiStdoutLine(line, ts);
|
||||
expect(entry).toMatchObject({
|
||||
kind: "result",
|
||||
inputTokens: 9095,
|
||||
outputTokens: 29,
|
||||
cachedTokens: 8132,
|
||||
isError: false,
|
||||
subtype: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags v0.38 result.status=error as an error", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "result",
|
||||
status: "error",
|
||||
error: "boom",
|
||||
});
|
||||
const [entry] = parseGeminiStdoutLine(line, ts);
|
||||
expect(entry).toMatchObject({ kind: "result", isError: true, errors: ["boom"] });
|
||||
});
|
||||
|
||||
it("ignores message events without an actionable role", () => {
|
||||
const line = JSON.stringify({ type: "message", role: "system", content: "ignored" });
|
||||
expect(parseGeminiStdoutLine(line, ts)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,7 @@ function readSessionId(parsed: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
function readUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata) ?? asRecord(parsed.stats);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
return {
|
||||
@@ -203,7 +203,7 @@ function readUsage(parsed: Record<string, unknown>) {
|
||||
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
|
||||
cachedTokens: asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, asNumber(source.cached))),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -237,6 +237,19 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
return collectTextEntries(parsed.message, ts, "user");
|
||||
}
|
||||
|
||||
// Gemini CLI v0.38+ stream-json schema:
|
||||
// {"type":"message","role":"assistant"|"user","content":"...","delta":?true}
|
||||
if (type === "message") {
|
||||
const role = asString(parsed.role).trim().toLowerCase();
|
||||
if (role === "assistant") {
|
||||
return parseAssistantMessage(parsed.content, ts);
|
||||
}
|
||||
if (role === "user") {
|
||||
return collectTextEntries(parsed.content, ts, "user");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
return text ? [{ kind: "thinking", ts, text }] : [];
|
||||
@@ -248,7 +261,10 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
|
||||
if (type === "result") {
|
||||
const usage = readUsage(parsed);
|
||||
const errors = parsed.is_error === true
|
||||
const status = asString(parsed.status).toLowerCase();
|
||||
const isError =
|
||||
parsed.is_error === true || status === "error" || status === "failed";
|
||||
const errors = isError
|
||||
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
|
||||
: [];
|
||||
return [{
|
||||
@@ -259,8 +275,8 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedTokens: usage.cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||
subtype: asString(parsed.subtype, "result"),
|
||||
isError: parsed.is_error === true,
|
||||
subtype: asString(parsed.subtype, status || "result"),
|
||||
isError,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -3,8 +3,17 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
export const type = "opencode_local";
|
||||
export const label = "OpenCode (local)";
|
||||
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g opencode-ai";
|
||||
|
||||
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
|
||||
|
||||
export function isValidOpenCodeModelId(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const trimmed = value.trim();
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
return Boolean(trimmed) && slashIndex > 0 && slashIndex !== trimmed.length - 1;
|
||||
}
|
||||
|
||||
export const models: Array<{ id: string; label: string }> = [
|
||||
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
|
||||
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
|
||||
|
||||
@@ -11,24 +11,38 @@ const {
|
||||
restoreWorkspaceFromSshExecution,
|
||||
runSshCommand,
|
||||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "step_start", sessionID: "session_123" }),
|
||||
JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }),
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
sessionID: "session_123",
|
||||
part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } },
|
||||
}),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
pid: 123,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]) => {
|
||||
if (args.includes("models")) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "opencode/gpt-5-nano\nopenai/gpt-4.1\n",
|
||||
stderr: "",
|
||||
pid: 122,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "step_start", sessionID: "session_123" }),
|
||||
JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }),
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
sessionID: "session_123",
|
||||
part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } },
|
||||
}),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
pid: 123,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
ensureCommandResolvable: vi.fn(async () => undefined),
|
||||
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: opencode"),
|
||||
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
|
||||
@@ -39,6 +53,14 @@ const {
|
||||
exitCode: 0,
|
||||
})),
|
||||
syncDirectoryToSsh: vi.fn(async () => undefined),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
||||
env: {
|
||||
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
||||
PAPERCLIP_API_KEY: "bridge-token",
|
||||
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
||||
},
|
||||
stop: async () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
@@ -66,6 +88,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
describe("opencode remote execution", () => {
|
||||
@@ -84,7 +116,9 @@ describe("opencode remote execution", () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
@@ -110,6 +144,20 @@ describe("opencode remote execution", () => {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: workspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: alternateWorkspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
],
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
@@ -121,7 +169,6 @@ describe("opencode remote execution", () => {
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
@@ -136,7 +183,6 @@ describe("opencode remote execution", () => {
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
});
|
||||
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
||||
@@ -153,15 +199,105 @@ describe("opencode remote execution", () => {
|
||||
expect.stringContaining(".claude/skills"),
|
||||
expect.anything(),
|
||||
);
|
||||
const call = runChildProcess.mock.calls[0] as unknown as
|
||||
const runCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as
|
||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||
| undefined;
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
|
||||
const modelProbeCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("models")) as
|
||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||
| undefined;
|
||||
expect(modelProbeCall?.[2]).toEqual(["models"]);
|
||||
expect(modelProbeCall?.[3].env.XDG_CONFIG_HOME).toBe(
|
||||
"/remote/workspace/.paperclip-runtime/opencode/xdgConfig",
|
||||
);
|
||||
expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
const call = runCall as
|
||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||
| undefined;
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
]);
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(call?.[3].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails before the remote run when the configured model is unavailable on the SSH target", async () => {
|
||||
runChildProcess.mockImplementationOnce(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "openai/gpt-4.1\n",
|
||||
stderr: "",
|
||||
pid: 456,
|
||||
startedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-model-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
await expect(() =>
|
||||
execute({
|
||||
runId: "run-ssh-model-missing",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "OpenCode Builder",
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
},
|
||||
context: {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
remoteCwd: "/remote/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("Configured OpenCode model is unavailable on the remote execution target");
|
||||
|
||||
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
||||
expect((runChildProcess.mock.calls[0]?.[2] as string[] | undefined) ?? []).toEqual(["models"]);
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
@@ -218,7 +354,9 @@ describe("opencode remote execution", () => {
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
||||
const call = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as
|
||||
| [string, string, string[]]
|
||||
| undefined;
|
||||
expect(call?.[2]).toContain("--session");
|
||||
expect(call?.[2]).toContain("session-123");
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetPaperclipApiUrl,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
readAdapterExecutionTargetHomeDir,
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
@@ -42,9 +43,14 @@ import {
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import {
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
parseOpenCodeModelsOutput,
|
||||
requireOpenCodeModelId,
|
||||
} from "./models.js";
|
||||
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -68,6 +74,64 @@ function resolveOpenCodeBiller(env: Record<string, string>, provider: string | n
|
||||
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
|
||||
}
|
||||
|
||||
const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20;
|
||||
|
||||
async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: {
|
||||
runId: string;
|
||||
executionTarget: NonNullable<AdapterExecutionContext["executionTarget"]>;
|
||||
command: string;
|
||||
model: string;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}) {
|
||||
const model = requireOpenCodeModelId(input.model);
|
||||
const probeTimeoutSec = input.timeoutSec > 0
|
||||
? Math.min(input.timeoutSec, REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC)
|
||||
: REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC;
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
input.runId,
|
||||
input.executionTarget,
|
||||
input.command,
|
||||
["models"],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: probeTimeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (probe.timedOut) {
|
||||
throw new Error(`\`opencode models\` timed out on the remote execution target after ${probeTimeoutSec}s.`);
|
||||
}
|
||||
|
||||
if ((probe.exitCode ?? 1) !== 0) {
|
||||
const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout);
|
||||
throw new Error(
|
||||
detail
|
||||
? `\`opencode models\` failed on the remote execution target: ${detail}`
|
||||
: "`opencode models` failed on the remote execution target.",
|
||||
);
|
||||
}
|
||||
|
||||
const models = parseOpenCodeModelsOutput(probe.stdout);
|
||||
if (models.length === 0) {
|
||||
throw new Error(
|
||||
"OpenCode returned no models on the remote execution target. Run `opencode models` there and verify provider auth.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!models.some((entry) => entry.id === model)) {
|
||||
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
|
||||
throw new Error(
|
||||
`Configured OpenCode model is unavailable on the remote execution target: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
@@ -155,6 +219,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceHints,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
||||
@@ -203,17 +274,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
@@ -234,14 +304,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
if (!executionTargetIsRemote) {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model,
|
||||
@@ -251,20 +333,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
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) {
|
||||
if (executionTarget?.kind === "remote") {
|
||||
localSkillsDir = await buildOpenCodeSkillsDir(config);
|
||||
await onLog(
|
||||
"stdout",
|
||||
@@ -274,6 +353,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
target: executionTarget,
|
||||
adapterKey: "opencode",
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [
|
||||
{
|
||||
key: "skills",
|
||||
@@ -315,6 +396,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
{ cwd, env: preparedRuntimeConfig.env, timeoutSec, graceSec, onLog },
|
||||
);
|
||||
}
|
||||
await ensureRemoteOpenCodeModelConfiguredAndAvailable({
|
||||
runId,
|
||||
executionTarget,
|
||||
command,
|
||||
model,
|
||||
cwd,
|
||||
env: preparedRuntimeConfig.env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
});
|
||||
}
|
||||
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
|
||||
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
|
||||
@@ -67,6 +67,7 @@ export {
|
||||
listOpenCodeModels,
|
||||
discoverOpenCodeModels,
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
requireOpenCodeModelId,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
listOpenCodeModels,
|
||||
requireOpenCodeModelId,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
|
||||
@@ -22,6 +23,19 @@ describe("openCode models", () => {
|
||||
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
|
||||
});
|
||||
|
||||
it("accepts a provider/model id without running discovery", () => {
|
||||
expect(requireOpenCodeModelId("openai/gpt-5.2-codex")).toBe("openai/gpt-5.2-codex");
|
||||
});
|
||||
|
||||
it("rejects malformed provider/model ids before discovery", () => {
|
||||
expect(() => requireOpenCodeModelId("gpt-5.2-codex")).toThrow(
|
||||
"OpenCode requires `adapterConfig.model`",
|
||||
);
|
||||
expect(() => requireOpenCodeModelId("openai/")).toThrow(
|
||||
"OpenCode requires `adapterConfig.model`",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when discovery cannot run for configured model", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
await expect(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isValidOpenCodeModelId } from "../index.js";
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
@@ -23,6 +24,14 @@ const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel
|
||||
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
|
||||
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]);
|
||||
|
||||
export function requireOpenCodeModelId(input: unknown): string {
|
||||
const model = asString(input, "").trim();
|
||||
if (!isValidOpenCodeModelId(model)) {
|
||||
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
@@ -50,7 +59,7 @@ function firstNonEmptyLine(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function parseModelsOutput(stdout: string): AdapterModel[] {
|
||||
export function parseOpenCodeModelsOutput(stdout: string): AdapterModel[] {
|
||||
const parsed: AdapterModel[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
@@ -144,7 +153,7 @@ export async function discoverOpenCodeModels(input: {
|
||||
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
|
||||
}
|
||||
|
||||
return sortModels(parseModelsOutput(result.stdout));
|
||||
return sortModels(parseOpenCodeModelsOutput(result.stdout));
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModelsCached(input: {
|
||||
@@ -172,10 +181,7 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
}): Promise<AdapterModel[]> {
|
||||
const model = asString(input.model, "").trim();
|
||||
if (!model) {
|
||||
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
|
||||
}
|
||||
const model = requireOpenCodeModelId(input.model);
|
||||
|
||||
const models = await discoverOpenCodeModelsCached({
|
||||
command: input.command,
|
||||
|
||||
@@ -34,6 +34,7 @@ async function readJsonObject(filepath: string): Promise<Record<string, unknown>
|
||||
export async function prepareOpenCodeRuntimeConfig(input: {
|
||||
env: Record<string, string>;
|
||||
config: Record<string, unknown>;
|
||||
targetIsRemote?: boolean;
|
||||
}): Promise<PreparedOpenCodeRuntimeConfig> {
|
||||
const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true);
|
||||
if (!skipPermissions) {
|
||||
@@ -44,6 +45,19 @@ export async function prepareOpenCodeRuntimeConfig(input: {
|
||||
};
|
||||
}
|
||||
|
||||
// For remote execution targets the host XDG_CONFIG_HOME path is meaningless
|
||||
// (and actively harmful — it leaks a macOS-only path into the remote Linux
|
||||
// env). Callers that need to ship a runtime opencode config to the remote
|
||||
// box do that via prepareAdapterExecutionTargetRuntime in execute.ts; this
|
||||
// host-fs helper is local-only.
|
||||
if (input.targetIsRemote) {
|
||||
return {
|
||||
env: input.env,
|
||||
notes: [],
|
||||
cleanup: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode");
|
||||
const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-"));
|
||||
const runtimeConfigDir = path.join(runtimeConfigHome, "opencode");
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
maybeRunSandboxInstallCommand,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { parseOpenCodeJsonl } from "./parse.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
@@ -116,7 +118,7 @@ export async function testEnvironment(
|
||||
|
||||
// Prevent OpenCode from writing an opencode.json into the working directory.
|
||||
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
|
||||
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
||||
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config, targetIsRemote });
|
||||
if (asBoolean(config.dangerouslySkipPermissions, true)) {
|
||||
checks.push({
|
||||
code: "opencode_headless_permissions_enabled",
|
||||
@@ -136,6 +138,15 @@ export async function testEnvironment(
|
||||
detail: command,
|
||||
});
|
||||
} else {
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "opencode",
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
@@ -279,6 +290,13 @@ export async function testEnvironment(
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
// For remote targets, do NOT spread the host process.env into the
|
||||
// probe env: it leaks macOS-only paths (HOME=/Users/..., host
|
||||
// XDG_CONFIG_HOME, TMPDIR, etc.) into the remote shell, which causes
|
||||
// opencode on the remote box to try to mkdir host paths like /Users.
|
||||
// Match the pattern used by claude_local / codex_local / gemini_local
|
||||
// probes: send only the user-configured adapter env across SSH.
|
||||
const probeEnv = targetIsRemote ? preparedRuntimeConfig.env : runtimeEnv;
|
||||
try {
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
@@ -287,7 +305,7 @@ export async function testEnvironment(
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
env: probeEnv,
|
||||
timeoutSec: 60,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
export const type = "pi_local";
|
||||
export const label = "Pi (local)";
|
||||
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent";
|
||||
|
||||
export const models: Array<{ id: string; label: string }> = [];
|
||||
|
||||
export const modelProfiles: AdapterModelProfileDefinition[] = [];
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
restoreWorkspaceFromSshExecution,
|
||||
runSshCommand,
|
||||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
@@ -44,6 +45,14 @@ const {
|
||||
exitCode: 0,
|
||||
})),
|
||||
syncDirectoryToSsh: vi.fn(async () => undefined),
|
||||
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
||||
env: {
|
||||
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
||||
PAPERCLIP_API_KEY: "bridge-token",
|
||||
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
||||
},
|
||||
stop: async () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
@@ -71,6 +80,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
describe("pi remote execution", () => {
|
||||
@@ -89,7 +108,9 @@ describe("pi remote execution", () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
@@ -115,6 +136,20 @@ describe("pi remote execution", () => {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: workspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: alternateWorkspaceDir,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
],
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
@@ -126,7 +161,6 @@ describe("pi remote execution", () => {
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
@@ -140,7 +174,6 @@ describe("pi remote execution", () => {
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
paperclipApiUrl: "http://198.51.100.10:3102",
|
||||
},
|
||||
});
|
||||
expect(String(result.sessionId)).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/");
|
||||
@@ -161,8 +194,24 @@ describe("pi remote execution", () => {
|
||||
expect(call?.[2]).toContain("--session");
|
||||
expect(call?.[2]).toContain("--skill");
|
||||
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills");
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "feature/other",
|
||||
},
|
||||
]);
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -172,6 +221,22 @@ describe("pi remote execution", () => {
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
runSshCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const command = String(args[1] ?? "");
|
||||
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
|
||||
return {
|
||||
stdout: `${JSON.stringify({ type: "session", cwd: "/remote/workspace" })}\n`,
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: "run-ssh-resume",
|
||||
agent: {
|
||||
@@ -226,4 +291,218 @@ describe("pi remote execution", () => {
|
||||
expect(call?.[2]).toContain("--session");
|
||||
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
|
||||
});
|
||||
|
||||
it("starts a fresh remote Pi session when the saved session header cwd points at a different workspace", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-stale-session-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
runSshCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const command = String(args[1] ?? "");
|
||||
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
|
||||
return {
|
||||
stdout: `${JSON.stringify({ type: "session", cwd: "/remote/old-workspace" })}\n`,
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: "run-ssh-stale-session",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Pi Builder",
|
||||
adapterType: "pi_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
|
||||
sessionParams: {
|
||||
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
|
||||
cwd: "/remote/workspace",
|
||||
remoteExecution: {
|
||||
transport: "ssh",
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
},
|
||||
},
|
||||
sessionDisplayId: "session-123",
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "pi",
|
||||
model: "openai/gpt-5.4-mini",
|
||||
},
|
||||
context: {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
remoteCwd: "/remote/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
||||
const sessionIndex = call?.[2].indexOf("--session") ?? -1;
|
||||
expect(sessionIndex).toBeGreaterThanOrEqual(0);
|
||||
const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null;
|
||||
expect(usedSession).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/");
|
||||
expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
|
||||
});
|
||||
|
||||
it("starts a fresh remote Pi session when the saved session header is empty or unreadable", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-empty-header-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
runSshCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const command = String(args[1] ?? "");
|
||||
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: "run-ssh-empty-header",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Pi Builder",
|
||||
adapterType: "pi_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
|
||||
sessionParams: {
|
||||
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
|
||||
cwd: "/remote/workspace",
|
||||
remoteExecution: {
|
||||
transport: "ssh",
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
},
|
||||
},
|
||||
sessionDisplayId: "session-123",
|
||||
taskKey: null,
|
||||
},
|
||||
config: { command: "pi", model: "openai/gpt-5.4-mini" },
|
||||
context: {
|
||||
paperclipWorkspace: { cwd: workspaceDir, source: "project_primary" },
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
remoteCwd: "/remote/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
||||
const sessionIndex = call?.[2].indexOf("--session") ?? -1;
|
||||
expect(sessionIndex).toBeGreaterThanOrEqual(0);
|
||||
const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null;
|
||||
expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
|
||||
});
|
||||
|
||||
it("starts a fresh remote Pi session when the remote head command fails", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-head-failure-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
runSshCommand.mockImplementation(async (...args: unknown[]) => {
|
||||
const command = String(args[1] ?? "");
|
||||
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
|
||||
throw Object.assign(new Error("ssh: connect failed"), {
|
||||
stdout: "",
|
||||
stderr: "ssh: connect failed",
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
});
|
||||
|
||||
await execute({
|
||||
runId: "run-ssh-head-failure",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Pi Builder",
|
||||
adapterType: "pi_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
|
||||
sessionParams: {
|
||||
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
|
||||
cwd: "/remote/workspace",
|
||||
remoteExecution: {
|
||||
transport: "ssh",
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
},
|
||||
},
|
||||
sessionDisplayId: "session-123",
|
||||
taskKey: null,
|
||||
},
|
||||
config: { command: "pi", model: "openai/gpt-5.4-mini" },
|
||||
context: {
|
||||
paperclipWorkspace: { cwd: workspaceDir, source: "project_primary" },
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
remoteCwd: "/remote/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
||||
const sessionIndex = call?.[2].indexOf("--session") ?? -1;
|
||||
expect(sessionIndex).toBeGreaterThanOrEqual(0);
|
||||
const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null;
|
||||
expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetPaperclipApiUrl,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
@@ -14,10 +13,12 @@ import {
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetFile,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
@@ -37,12 +38,15 @@ import {
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
||||
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
|
||||
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -143,6 +147,68 @@ function buildRemoteSessionPath(runtimeRootDir: string, agentId: string, timesta
|
||||
return path.posix.join(runtimeRootDir, "sessions", `${safeTimestamp}-${agentId}.jsonl`);
|
||||
}
|
||||
|
||||
function normalizeExecutionCwd(candidate: string, remote: boolean): string {
|
||||
return remote ? path.posix.normalize(candidate) : path.resolve(candidate);
|
||||
}
|
||||
|
||||
function executionCwdsMatch(saved: string, current: string, remote: boolean): boolean {
|
||||
return normalizeExecutionCwd(saved, remote) === normalizeExecutionCwd(current, remote);
|
||||
}
|
||||
|
||||
function readSessionHeaderCwd(raw: string): string | null {
|
||||
const headerLine = raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (!headerLine) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(headerLine) as Record<string, unknown>;
|
||||
if (parsed.type !== "session") return null;
|
||||
const cwd = typeof parsed.cwd === "string" ? parsed.cwd.trim() : "";
|
||||
return cwd.length > 0 ? cwd : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSavedSessionCwd(input: {
|
||||
runId: string;
|
||||
sessionPath: string;
|
||||
executionTarget: ReturnType<typeof readAdapterExecutionTarget>;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}): Promise<string | null> {
|
||||
if (!input.sessionPath.trim()) return null;
|
||||
|
||||
if (!adapterExecutionTargetIsRemote(input.executionTarget)) {
|
||||
try {
|
||||
return readSessionHeaderCwd(await fs.readFile(input.sessionPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionHeader = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
input.executionTarget,
|
||||
`if [ -f ${shellQuote(input.sessionPath)} ]; then head -n 1 ${shellQuote(input.sessionPath)}; fi`,
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: input.timeoutSec > 0 ? Math.min(input.timeoutSec, 15) : 15,
|
||||
graceSec: input.graceSec,
|
||||
},
|
||||
);
|
||||
if (sessionHeader.timedOut || (sessionHeader.exitCode ?? 0) !== 0) return null;
|
||||
return readSessionHeaderCwd(sessionHeader.stdout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||
const executionTarget = readAdapterExecutionTarget({
|
||||
@@ -180,6 +246,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceHints,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
if (!executionTargetIsRemote) {
|
||||
@@ -232,17 +304,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd,
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
agentHome,
|
||||
});
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
@@ -278,7 +349,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
let loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
@@ -295,8 +379,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -319,6 +401,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
target: executionTarget,
|
||||
adapterKey: "pi",
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [
|
||||
{
|
||||
key: "skills",
|
||||
@@ -368,10 +452,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||
const sessionTargetMatches = adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
|
||||
const sessionParamsCwdMatches =
|
||||
runtimeSessionCwd.length === 0 ||
|
||||
executionCwdsMatch(runtimeSessionCwd, effectiveExecutionCwd, executionTargetIsRemote);
|
||||
const savedSessionCwd =
|
||||
runtimeSessionId.length > 0
|
||||
? await readSavedSessionCwd({
|
||||
runId,
|
||||
sessionPath: runtimeSessionId,
|
||||
executionTarget,
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
})
|
||||
: null;
|
||||
const sessionHeaderCwdMatches =
|
||||
runtimeSessionId.length === 0 ||
|
||||
(savedSessionCwd !== null &&
|
||||
executionCwdsMatch(savedSessionCwd, effectiveExecutionCwd, executionTargetIsRemote));
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
|
||||
sessionTargetMatches &&
|
||||
sessionParamsCwdMatches &&
|
||||
sessionHeaderCwdMatches;
|
||||
const sessionPath = canResumeSession
|
||||
? runtimeSessionId
|
||||
: executionTargetIsRemote && remoteRuntimeRootDir
|
||||
@@ -379,11 +484,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
: buildSessionPath(agent.id, new Date().toISOString());
|
||||
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
const staleSessionCwdNote =
|
||||
savedSessionCwd !== null && !sessionHeaderCwdMatches
|
||||
? ` Pi stored cwd "${savedSessionCwd}" in the session header, so Paperclip will start a fresh session for "${effectiveExecutionCwd}".`
|
||||
: "";
|
||||
await onLog(
|
||||
"stdout",
|
||||
executionTargetIsRemote
|
||||
? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`
|
||||
: `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
||||
? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution state and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote} Starting a fresh remote session.\n`
|
||||
: `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
maybeRunSandboxInstallCommand,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import { discoverPiModelsCached } from "./models.js";
|
||||
import { parsePiJsonl } from "./parse.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
@@ -134,6 +136,15 @@ export async function testEnvironment(
|
||||
detail: command,
|
||||
});
|
||||
} else {
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "pi",
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
@@ -248,6 +259,17 @@ export async function testEnvironment(
|
||||
args.push("--tools", "read");
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
// For remote targets, do NOT spread the host process.env into the probe
|
||||
// env: it leaks macOS-only PATH, HOME, TMPDIR, etc. into the remote shell.
|
||||
// In particular the Mac PATH overrides the nvm-sourced PATH that
|
||||
// buildSshSpawnTarget sets up, which on Linux SSH targets resolves `node`
|
||||
// to /usr/bin/node (Node 18) instead of nvm's Node 22, causing pi-tui to
|
||||
// crash with `Invalid regular expression flags` on its /v unicode regex.
|
||||
// Match the pattern used by claude_local / codex_local / gemini_local /
|
||||
// opencode_local probes: send only the user-configured adapter env across
|
||||
// SSH. Local probes still get the full runtimeEnv.
|
||||
const probeEnv = targetIsRemote ? normalizeEnv(env) : runtimeEnv;
|
||||
|
||||
try {
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
@@ -256,7 +278,7 @@ export async function testEnvironment(
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
env: probeEnv,
|
||||
timeoutSec: 60,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS "plugin_managed_resources" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"plugin_key" text NOT NULL,
|
||||
"resource_kind" text NOT NULL,
|
||||
"resource_key" text NOT NULL,
|
||||
"resource_id" uuid NOT NULL,
|
||||
"defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_company_idx" ON "plugin_managed_resources" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_plugin_idx" ON "plugin_managed_resources" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_resource_idx" ON "plugin_managed_resources" USING btree ("resource_kind","resource_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_managed_resources_company_plugin_resource_uq" ON "plugin_managed_resources" USING btree ("company_id","plugin_id","resource_kind","resource_key");
|
||||
@@ -0,0 +1,140 @@
|
||||
CREATE TABLE IF NOT EXISTS "routine_revisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"routine_id" uuid NOT NULL,
|
||||
"revision_number" integer NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"snapshot" jsonb NOT NULL,
|
||||
"change_summary" text,
|
||||
"restored_from_revision_id" uuid,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_by_run_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "latest_revision_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "latest_revision_number" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_restored_from_revision_id_routine_revisions_id_fk" FOREIGN KEY ("restored_from_revision_id") REFERENCES "public"."routine_revisions"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "routine_revisions_routine_revision_uq" ON "routine_revisions" USING btree ("routine_id","revision_number");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "routine_revisions_company_routine_created_idx" ON "routine_revisions" USING btree ("company_id","routine_id","created_at");
|
||||
--> statement-breakpoint
|
||||
WITH inserted_revisions AS (
|
||||
INSERT INTO "routine_revisions" (
|
||||
"id",
|
||||
"company_id",
|
||||
"routine_id",
|
||||
"revision_number",
|
||||
"title",
|
||||
"description",
|
||||
"snapshot",
|
||||
"change_summary",
|
||||
"created_by_agent_id",
|
||||
"created_by_user_id",
|
||||
"created_at"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
r."company_id",
|
||||
r."id",
|
||||
1,
|
||||
r."title",
|
||||
r."description",
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'routine', jsonb_build_object(
|
||||
'id', r."id",
|
||||
'companyId', r."company_id",
|
||||
'projectId', r."project_id",
|
||||
'goalId', r."goal_id",
|
||||
'parentIssueId', r."parent_issue_id",
|
||||
'title', r."title",
|
||||
'description', r."description",
|
||||
'assigneeAgentId', r."assignee_agent_id",
|
||||
'priority', r."priority",
|
||||
'status', r."status",
|
||||
'concurrencyPolicy', r."concurrency_policy",
|
||||
'catchUpPolicy', r."catch_up_policy",
|
||||
'variables', coalesce(r."variables", '[]'::jsonb)
|
||||
),
|
||||
'triggers', coalesce(
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', rt."id",
|
||||
'kind', rt."kind",
|
||||
'label', rt."label",
|
||||
'enabled', rt."enabled",
|
||||
'cronExpression', rt."cron_expression",
|
||||
'timezone', rt."timezone",
|
||||
'publicId', rt."public_id",
|
||||
'signingMode', rt."signing_mode",
|
||||
'replayWindowSec', rt."replay_window_sec"
|
||||
)
|
||||
ORDER BY rt."created_at", rt."id"
|
||||
)
|
||||
FROM "routine_triggers" rt
|
||||
WHERE rt."routine_id" = r."id"
|
||||
AND rt."company_id" = r."company_id"
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
),
|
||||
'Initial routine revision backfill',
|
||||
r."created_by_agent_id",
|
||||
r."created_by_user_id",
|
||||
r."created_at"
|
||||
FROM "routines" r
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "routine_revisions" rr
|
||||
WHERE rr."routine_id" = r."id"
|
||||
AND rr."revision_number" = 1
|
||||
)
|
||||
RETURNING "id", "routine_id"
|
||||
)
|
||||
UPDATE "routines" r
|
||||
SET
|
||||
"latest_revision_id" = inserted_revisions."id",
|
||||
"latest_revision_number" = 1
|
||||
FROM inserted_revisions
|
||||
WHERE r."id" = inserted_revisions."routine_id";
|
||||
--> statement-breakpoint
|
||||
UPDATE "routines" r
|
||||
SET
|
||||
"latest_revision_id" = rr."id",
|
||||
"latest_revision_number" = rr."revision_number"
|
||||
FROM "routine_revisions" rr
|
||||
WHERE rr."routine_id" = r."id"
|
||||
AND rr."revision_number" = 1
|
||||
AND r."latest_revision_id" IS NULL;
|
||||
File renamed without changes.
File renamed without changes.
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -537,15 +537,29 @@
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "7",
|
||||
"when": 1777821410992,
|
||||
"tag": "0076_company_search_document_indexes",
|
||||
"when": 1777675301279,
|
||||
"tag": "0076_useful_elektra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1777933347806,
|
||||
"tag": "0077_unusual_karnak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 78,
|
||||
"version": "7",
|
||||
"when": 1777821410992,
|
||||
"tag": "0078_company_search_document_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 79,
|
||||
"version": "7",
|
||||
"when": 1777849000000,
|
||||
"tag": "0077_company_search_fuzzystrmatch",
|
||||
"tag": "0079_company_search_fuzzystrmatch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -31,7 +31,7 @@ export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueReferenceMentions } from "./issue_reference_mentions.js";
|
||||
export { issueRelations } from "./issue_relations.js";
|
||||
export { routines, routineTriggers, routineRuns } from "./routines.js";
|
||||
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
|
||||
export { issueWorkProducts } from "./issue_work_products.js";
|
||||
export { labels } from "./labels.js";
|
||||
export { issueLabels } from "./issue_labels.js";
|
||||
@@ -65,6 +65,7 @@ export { companySkills } from "./company_skills.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||
export { pluginManagedResources } from "./plugin_managed_resources.js";
|
||||
export { pluginState } from "./plugin_state.js";
|
||||
export { pluginEntities } from "./plugin_entities.js";
|
||||
export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
export const pluginManagedResources = pgTable(
|
||||
"plugin_managed_resources",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
pluginKey: text("plugin_key").notNull(),
|
||||
resourceKind: text("resource_kind").notNull(),
|
||||
resourceKey: text("resource_key").notNull(),
|
||||
resourceId: uuid("resource_id").notNull(),
|
||||
defaultsJson: jsonb("defaults_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("plugin_managed_resources_company_idx").on(table.companyId),
|
||||
pluginIdx: index("plugin_managed_resources_plugin_idx").on(table.pluginId),
|
||||
resourceIdx: index("plugin_managed_resources_resource_idx").on(table.resourceKind, table.resourceId),
|
||||
companyPluginResourceUq: uniqueIndex("plugin_managed_resources_company_plugin_resource_uq").on(
|
||||
table.companyId,
|
||||
table.pluginId,
|
||||
table.resourceKind,
|
||||
table.resourceKey,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
@@ -15,7 +16,8 @@ import { companySecrets } from "./company_secrets.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { goals } from "./goals.js";
|
||||
import type { RoutineVariable } from "@paperclipai/shared";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import type { RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared";
|
||||
|
||||
export const routines = pgTable(
|
||||
"routines",
|
||||
@@ -33,6 +35,8 @@ export const routines = pgTable(
|
||||
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
|
||||
catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
|
||||
variables: jsonb("variables").$type<RoutineVariable[]>().notNull().default([]),
|
||||
latestRevisionId: uuid("latest_revision_id"),
|
||||
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
@@ -49,6 +53,39 @@ export const routines = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const routineRevisions = pgTable(
|
||||
"routine_revisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
routineId: uuid("routine_id").notNull().references(() => routines.id, { onDelete: "cascade" }),
|
||||
revisionNumber: integer("revision_number").notNull(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
snapshot: jsonb("snapshot").$type<RoutineRevisionSnapshotV1>().notNull(),
|
||||
changeSummary: text("change_summary"),
|
||||
restoredFromRevisionId: uuid("restored_from_revision_id").references(
|
||||
(): AnyPgColumn => routineRevisions.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
routineRevisionUq: uniqueIndex("routine_revisions_routine_revision_uq").on(
|
||||
table.routineId,
|
||||
table.revisionNumber,
|
||||
),
|
||||
companyRoutineCreatedIdx: index("routine_revisions_company_routine_created_idx").on(
|
||||
table.companyId,
|
||||
table.routineId,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const routineTriggers = pgTable(
|
||||
"routine_triggers",
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ Generates:
|
||||
- `esbuild` and `rollup` config files using SDK bundler presets
|
||||
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
||||
|
||||
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
|
||||
The scaffold starts with plain React elements so the generated plugin stays minimal. For Paperclip-native controls, import shared host components such as `MarkdownEditor`, `FileTree`, `AssigneePicker`, and `ProjectPicker` from `@paperclipai/plugin-sdk/ui`.
|
||||
|
||||
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type {
|
||||
FileTreeNode,
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginDetailTabProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
||||
import { FileTree, usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
import { useCallback, useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
@@ -129,15 +130,31 @@ const editorLightHighlightStyle = HighlightStyle.define([
|
||||
|
||||
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
|
||||
type FileEntry = { name: string; path: string; isDirectory: boolean };
|
||||
type FileTreeNodeProps = {
|
||||
entry: FileEntry;
|
||||
companyId: string | null;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
function entryToFileTreeNode(entry: FileEntry): FileTreeNode {
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
kind: entry.isDirectory ? "dir" : "file",
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function entriesToFileTreeNodes(entries: FileEntry[]): FileTreeNode[] {
|
||||
return entries.map(entryToFileTreeNode);
|
||||
}
|
||||
|
||||
function setChildrenAtPath(nodes: FileTreeNode[], path: string, children: FileTreeNode[]): FileTreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.path === path) {
|
||||
return { ...node, children };
|
||||
}
|
||||
if (node.kind === "dir" && node.children.length > 0 && (path === node.path || path.startsWith(`${node.path}/`))) {
|
||||
return { ...node, children: setChildrenAtPath(node.children, path, children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
const PathLikePattern = /[\\/]/;
|
||||
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
|
||||
@@ -235,109 +252,6 @@ function useAvailableHeight(
|
||||
return height;
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
entry,
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth = 0,
|
||||
}: FileTreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isSelected = selectedPath === entry.path;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60"
|
||||
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||
onClick={() => setIsExpanded((value) => !value)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span>
|
||||
<span className="truncate font-medium">{entry.name}</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<ExpandedDirectoryChildren
|
||||
directoryPath={entry.path}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 14 + 23}px` }}
|
||||
onClick={() => onSelect(entry.path)}
|
||||
>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedDirectoryChildren({
|
||||
directoryPath,
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
directoryPath: string;
|
||||
companyId: string | null;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
directoryPath,
|
||||
});
|
||||
const children = childData?.entries ?? [];
|
||||
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{children.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.path}
|
||||
entry={child}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
|
||||
*/
|
||||
@@ -430,11 +344,60 @@ export function FilesTab({ context }: PluginDetailTabProps) {
|
||||
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
|
||||
[companyId, projectId, selectedWorkspace],
|
||||
);
|
||||
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
|
||||
const { data: fileListData, loading: fileListLoading, error: fileListError } = usePluginData<{ entries: FileEntry[] }>(
|
||||
"fileList",
|
||||
fileListParams,
|
||||
);
|
||||
const entries = fileListData?.entries ?? [];
|
||||
|
||||
// Lazy-load directory children through an imperative action so the shared
|
||||
// FileTree can reuse `expandedPaths` for state without spawning a hook per
|
||||
// expanded directory.
|
||||
const loadFileList = usePluginAction("loadFileList");
|
||||
const [nodes, setNodes] = useState<FileTreeNode[]>([]);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
|
||||
const [loadedDirs, setLoadedDirs] = useState<Set<string>>(() => new Set());
|
||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(fileListData?.entries ? entriesToFileTreeNodes(fileListData.entries) : []);
|
||||
setExpandedPaths(new Set());
|
||||
setLoadedDirs(new Set());
|
||||
setLoadingDirs(new Set());
|
||||
}, [fileListData, selectedWorkspace?.id]);
|
||||
|
||||
const handleToggleDir = useCallback(
|
||||
(dirPath: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(dirPath)) next.delete(dirPath);
|
||||
else next.add(dirPath);
|
||||
return next;
|
||||
});
|
||||
if (!selectedWorkspace) return;
|
||||
if (loadedDirs.has(dirPath) || loadingDirs.has(dirPath)) return;
|
||||
setLoadingDirs((current) => new Set(current).add(dirPath));
|
||||
void loadFileList({
|
||||
projectId,
|
||||
companyId,
|
||||
workspaceId: selectedWorkspace.id,
|
||||
directoryPath: dirPath,
|
||||
})
|
||||
.then((response) => {
|
||||
const entries = (response as { entries?: FileEntry[] })?.entries ?? [];
|
||||
const children = entriesToFileTreeNodes(entries);
|
||||
setNodes((current) => setChildrenAtPath(current, dirPath, children));
|
||||
setLoadedDirs((current) => new Set(current).add(dirPath));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingDirs((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(dirPath);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[companyId, loadFileList, loadedDirs, loadingDirs, projectId, selectedWorkspace],
|
||||
);
|
||||
|
||||
// Track the `?file=` query parameter across navigations (popstate).
|
||||
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
|
||||
@@ -610,28 +573,23 @@ export function FilesTab({ context }: PluginDetailTabProps) {
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{selectedWorkspace ? (
|
||||
fileListLoading ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
|
||||
) : entries.length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeNode
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={selectedWorkspace.id}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
setSelectedPath(path);
|
||||
setMobileView("editor");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
|
||||
)
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleDir={handleToggleDir}
|
||||
onSelectFile={(path: string) => {
|
||||
setSelectedPath(path);
|
||||
setMobileView("editor");
|
||||
}}
|
||||
loading={fileListLoading}
|
||||
error={fileListError ? { message: fileListError.message } : null}
|
||||
empty={{
|
||||
title: "No files",
|
||||
description: "No files found in this workspace.",
|
||||
}}
|
||||
ariaLabel="Workspace files"
|
||||
/>
|
||||
) : (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
|
||||
)}
|
||||
|
||||
@@ -106,43 +106,46 @@ const plugin = definePlugin({
|
||||
}));
|
||||
});
|
||||
|
||||
ctx.data.register(
|
||||
"fileList",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
||||
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { entries: [] };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { entries: [] };
|
||||
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
||||
if (!dirPath) {
|
||||
return { entries: [] };
|
||||
}
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return { entries: [] };
|
||||
}
|
||||
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
||||
const entries = names.map((name) => {
|
||||
const full = path.join(dirPath, name);
|
||||
const stat = fs.lstatSync(full);
|
||||
const relativePath = path.relative(workspacePath, full);
|
||||
return {
|
||||
name,
|
||||
path: relativePath,
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return { entries };
|
||||
},
|
||||
);
|
||||
async function readFileList(params: Record<string, unknown>) {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
||||
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { entries: [] };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { entries: [] };
|
||||
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
||||
if (!dirPath) {
|
||||
return { entries: [] };
|
||||
}
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return { entries: [] };
|
||||
}
|
||||
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
||||
const entries = names.map((name) => {
|
||||
const full = path.join(dirPath, name);
|
||||
const stat = fs.lstatSync(full);
|
||||
const relativePath = path.relative(workspacePath, full);
|
||||
return {
|
||||
name,
|
||||
path: relativePath,
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return { entries };
|
||||
}
|
||||
|
||||
ctx.data.register("fileList", readFileList);
|
||||
|
||||
// Mirror `fileList` as an action so the UI can lazily fetch directory
|
||||
// children on tree expand without spawning a usePluginData hook per dir.
|
||||
ctx.actions.register("loadFileList", readFileList);
|
||||
|
||||
ctx.data.register(
|
||||
"fileContent",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
AssigneePicker,
|
||||
ProjectPicker,
|
||||
useHostContext,
|
||||
useHostNavigation,
|
||||
usePluginAction,
|
||||
usePluginData,
|
||||
usePluginStream,
|
||||
@@ -248,14 +251,6 @@ const mutedTextStyle: CSSProperties = {
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
function hostPath(companyPrefix: string | null | undefined, suffix: string): string {
|
||||
return companyPrefix ? `/${companyPrefix}${suffix}` : suffix;
|
||||
}
|
||||
|
||||
function pluginPagePath(companyPrefix: string | null | undefined): string {
|
||||
return hostPath(companyPrefix, `/${PAGE_ROUTE}`);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -521,6 +516,7 @@ function CompactSurfaceSummary({ label, entityType }: { label: string; entityTyp
|
||||
function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) {
|
||||
const overview = usePluginOverview(context.companyId);
|
||||
const toast = usePluginToast();
|
||||
const hostNavigation = useHostNavigation();
|
||||
const emitDemoEvent = usePluginAction("emit-demo-event");
|
||||
const startProgressStream = usePluginAction("start-progress-stream");
|
||||
const writeMetric = usePluginAction("write-metric");
|
||||
@@ -591,7 +587,7 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
|
||||
tone: "info",
|
||||
action: {
|
||||
label: "Go",
|
||||
href: hostPath(context.companyPrefix, "/dashboard"),
|
||||
href: hostNavigation.resolveHref("/dashboard"),
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -1079,6 +1075,7 @@ function KitchenSinkCompanyCrudDemo({ context }: { context: PluginPageProps["con
|
||||
}
|
||||
|
||||
function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -1098,8 +1095,8 @@ function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] })
|
||||
<div style={mutedTextStyle}>
|
||||
The company sidebar entry opens this route directly, so the plugin feels like a first-class company page instead of a settings subpage.
|
||||
</div>
|
||||
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>
|
||||
{pluginPagePath(context.companyPrefix)}
|
||||
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>
|
||||
{hostNavigation.resolveHref(`/${PAGE_ROUTE}`)}
|
||||
</a>
|
||||
</Section>
|
||||
<Section title="Paperclip Animation">
|
||||
@@ -1193,6 +1190,7 @@ function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context
|
||||
}
|
||||
|
||||
function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const [liveRuns, setLiveRuns] = useState<HostLiveRunRecord[]>([]);
|
||||
const [recentRuns, setRecentRuns] = useState<HostHeartbeatRunRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -1228,7 +1226,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
|
||||
<div style={subtleCardStyle}>
|
||||
<div style={rowStyle}>
|
||||
<strong>Company Route</strong>
|
||||
<Pill label={pluginPagePath(context.companyPrefix)} />
|
||||
<Pill label={hostNavigation.resolveHref(`/${PAGE_ROUTE}`)} />
|
||||
</div>
|
||||
<div style={mutedTextStyle}>
|
||||
This page is mounted as a real company route instead of living only under `/plugins/:pluginId`.
|
||||
@@ -1260,7 +1258,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
|
||||
</div>
|
||||
<div>{run.id}</div>
|
||||
{run.agentId ? (
|
||||
<a href={hostPath(context.companyPrefix, `/agents/${run.agentId}/runs/${run.id}`)}>
|
||||
<a {...hostNavigation.linkProps(`/agents/${run.agentId}/runs/${run.id}`)}>
|
||||
Open run
|
||||
</a>
|
||||
) : null}
|
||||
@@ -1294,6 +1292,44 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
|
||||
);
|
||||
}
|
||||
|
||||
function KitchenSinkSharedPickerDemo({ context }: { context: PluginPageProps["context"] }) {
|
||||
const [assigneeValue, setAssigneeValue] = useState("");
|
||||
const [projectId, setProjectId] = useState(context.projectId ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setProjectId(context.projectId ?? "");
|
||||
}, [context.projectId]);
|
||||
|
||||
return (
|
||||
<Section title="Shared Host Pickers">
|
||||
<div style={mutedTextStyle}>
|
||||
These controls are imported from `@paperclipai/plugin-sdk/ui` and reuse the host's assignee and project pickers from the new issue pane.
|
||||
</div>
|
||||
{!context.companyId ? (
|
||||
<div style={mutedTextStyle}>Select a company to load picker options.</div>
|
||||
) : (
|
||||
<div style={subtleCardStyle}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" }}>
|
||||
<AssigneePicker
|
||||
companyId={context.companyId}
|
||||
value={assigneeValue}
|
||||
onChange={(value) => setAssigneeValue(value)}
|
||||
/>
|
||||
<ProjectPicker
|
||||
companyId={context.companyId}
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ ...mutedTextStyle, marginTop: "8px" }}>
|
||||
Selected assignee: {assigneeValue || "none"}, selected project: {projectId || "none"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) {
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "14px" }}>
|
||||
@@ -1301,12 +1337,14 @@ function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context
|
||||
<KitchenSinkStorageDemo context={context} />
|
||||
<KitchenSinkIssueCrudDemo context={context} />
|
||||
<KitchenSinkCompanyCrudDemo context={context} />
|
||||
<KitchenSinkSharedPickerDemo context={context} />
|
||||
<KitchenSinkHostIntegrationDemo context={context} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const companyId = context.companyId;
|
||||
const overview = usePluginOverview(companyId);
|
||||
const [companiesLimit, setCompaniesLimit] = useState(20);
|
||||
@@ -1531,10 +1569,10 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||
|
||||
<Section title="UI Surfaces">
|
||||
<div style={rowStyle}>
|
||||
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>Open plugin page</a>
|
||||
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>Open plugin page</a>
|
||||
{projectRef ? (
|
||||
<a
|
||||
href={hostPath(context.companyPrefix, `/projects/${projectRef}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
||||
{...hostNavigation.linkProps(`/projects/${projectRef}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
Open project tab
|
||||
@@ -1542,7 +1580,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||
) : null}
|
||||
{selectedIssueId ? (
|
||||
<a
|
||||
href={hostPath(context.companyPrefix, `/issues/${selectedIssueId}`)}
|
||||
{...hostNavigation.linkProps(`/issues/${selectedIssueId}`)}
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
Open selected issue
|
||||
@@ -2199,6 +2237,7 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
|
||||
}
|
||||
|
||||
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const overview = usePluginOverview(context.companyId);
|
||||
const writeMetric = usePluginAction("write-metric");
|
||||
|
||||
@@ -2217,7 +2256,7 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||
<div>Issues: {overview.data?.counts.issues ?? 0}</div>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>Open page</a>
|
||||
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>Open page</a>
|
||||
<button
|
||||
type="button"
|
||||
style={buttonStyle}
|
||||
@@ -2234,13 +2273,14 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||
}
|
||||
|
||||
export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const config = usePluginConfigData();
|
||||
if (config.data && config.data.showSidebarEntry === false) return null;
|
||||
const href = pluginPagePath(context.companyPrefix);
|
||||
const href = hostNavigation.resolveHref(`/${PAGE_ROUTE}`);
|
||||
const isActive = typeof window !== "undefined" && window.location.pathname === href;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...hostNavigation.linkProps(`/${PAGE_ROUTE}`)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={[
|
||||
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
@@ -2267,6 +2307,7 @@ export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
|
||||
|
||||
export function KitchenSinkSidebarPanel() {
|
||||
const context = useHostContext();
|
||||
const hostNavigation = useHostNavigation();
|
||||
const config = usePluginConfigData();
|
||||
const overview = usePluginOverview(context.companyId);
|
||||
if (config.data && config.data.showSidebarPanel === false) return null;
|
||||
@@ -2274,17 +2315,18 @@ export function KitchenSinkSidebarPanel() {
|
||||
<div style={{ ...layoutStack, ...subtleCardStyle, fontSize: "12px" }}>
|
||||
<strong>Kitchen Sink Panel</strong>
|
||||
<div>Recent plugin records: {overview.data?.recentRecords.length ?? 0}</div>
|
||||
<a href={pluginPagePath(context.companyPrefix)}>Open plugin page</a>
|
||||
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)}>Open plugin page</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KitchenSinkProjectSidebarItem({ context }: PluginProjectSidebarItemProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const config = usePluginConfigData();
|
||||
if (config.data && config.data.showProjectSidebarItem === false) return null;
|
||||
return (
|
||||
<a
|
||||
href={hostPath(context.companyPrefix, `/projects/${context.entityId}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
||||
{...hostNavigation.linkProps(`/projects/${context.entityId}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
||||
style={{ fontSize: "12px", textDecoration: "none" }}
|
||||
>
|
||||
Kitchen Sink
|
||||
|
||||
@@ -303,17 +303,14 @@ describe("E2B sandbox provider plugin", () => {
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledWith("sandbox-123", expect.objectContaining({ apiKey: "resolved-key" }));
|
||||
expect(sandbox.files.write).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/), "input");
|
||||
expect(sandbox.commands.run).toHaveBeenCalledWith(expect.stringMatching(
|
||||
/^exec 'printf' 'hello' < '\/tmp\/paperclip-stdin-/,
|
||||
), expect.objectContaining({
|
||||
cwd: "/workspace",
|
||||
envs: { FOO: "bar" },
|
||||
timeoutMs: 1000,
|
||||
}));
|
||||
expect(sandbox.commands.run).not.toHaveBeenCalledWith(
|
||||
"exec 'printf' 'hello'",
|
||||
expect.objectContaining({ background: true }),
|
||||
);
|
||||
const stdinCall = sandbox.commands.run.mock.calls.find(([cmd]: [string]) => cmd.includes("'printf'"));
|
||||
expect(stdinCall).toBeDefined();
|
||||
if (!stdinCall) throw new Error("stdinCall not found");
|
||||
expect(stdinCall[0]).toMatch(/\.profile/);
|
||||
expect(stdinCall[0]).toMatch(/exec env FOO='bar' 'printf' 'hello' < '\/tmp\/paperclip-stdin-/);
|
||||
expect(stdinCall[1]).toEqual(expect.objectContaining({ cwd: "/workspace", timeoutMs: 1000 }));
|
||||
expect(stdinCall[1]).not.toHaveProperty("envs");
|
||||
expect(stdinCall[1]).not.toHaveProperty("background");
|
||||
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.commands.closeStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.handle.wait).not.toHaveBeenCalled();
|
||||
@@ -363,15 +360,14 @@ describe("E2B sandbox provider plugin", () => {
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(sandbox.commands.run).toHaveBeenCalledWith("exec 'printf' 'hello'", expect.objectContaining({
|
||||
cwd: "/workspace",
|
||||
envs: { FOO: "bar" },
|
||||
timeoutMs: 1000,
|
||||
}));
|
||||
expect(sandbox.commands.run).not.toHaveBeenCalledWith(
|
||||
"exec 'printf' 'hello'",
|
||||
expect.objectContaining({ background: true }),
|
||||
);
|
||||
const fgCall = sandbox.commands.run.mock.calls.find(([cmd]: [string]) => cmd.includes("'printf'"));
|
||||
expect(fgCall).toBeDefined();
|
||||
if (!fgCall) throw new Error("fgCall not found");
|
||||
expect(fgCall[0]).toMatch(/\.profile/);
|
||||
expect(fgCall[0]).toMatch(/exec env FOO='bar' 'printf' 'hello'$/);
|
||||
expect(fgCall[1]).toEqual(expect.objectContaining({ cwd: "/workspace", timeoutMs: 1000 }));
|
||||
expect(fgCall[1]).not.toHaveProperty("envs");
|
||||
expect(fgCall[1]).not.toHaveProperty("background");
|
||||
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.commands.closeStdin).not.toHaveBeenCalled();
|
||||
expect(sandbox.handle.wait).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { CommandExitError, Sandbox, SandboxNotFoundError, TimeoutError } from "e2b";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
CommandExitError,
|
||||
Sandbox,
|
||||
SandboxNotFoundError,
|
||||
TimeoutError,
|
||||
} from "e2b";
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
@@ -127,6 +133,7 @@ function leaseMetadata(input: {
|
||||
}) {
|
||||
return {
|
||||
provider: "e2b",
|
||||
shellCommand: "bash",
|
||||
template: input.config.template,
|
||||
timeoutMs: input.config.timeoutMs,
|
||||
reuseLease: input.config.reuseLease,
|
||||
@@ -141,8 +148,48 @@ function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildCommandLine(command: string, args: string[] = []) {
|
||||
return `exec ${[command, ...args].map(shellQuote).join(" ")}`;
|
||||
function isValidShellEnvKey(value: string) {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
// Mirror SSH's buildSshSpawnTarget: source the user's login profiles (and nvm)
|
||||
// before exec so commands run with the same PATH the user sees in an
|
||||
// interactive shell. e2b's `sandbox.commands.run` otherwise spawns a
|
||||
// non-login, non-interactive shell whose PATH does not include npm-globals,
|
||||
// nvm shims, or anything else the template installs via .profile/.bashrc —
|
||||
// which makes the hello probe fail with `exec: <cli>: not found` even when
|
||||
// the binary is on disk.
|
||||
function buildLoginShellScript(input: {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}): string {
|
||||
const env = input.env ?? {};
|
||||
for (const key of Object.keys(env)) {
|
||||
if (!isValidShellEnvKey(key)) {
|
||||
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
||||
}
|
||||
}
|
||||
const envArgs = Object.entries(env)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
||||
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
|
||||
const execLine = envArgs.length > 0
|
||||
? `exec env ${envArgs.join(" ")} ${commandParts}`
|
||||
: `exec ${commandParts}`;
|
||||
return [
|
||||
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
||||
// .bash_profile typically sources .bashrc itself; only source .bashrc
|
||||
// directly when no .bash_profile exists to avoid re-running idempotency-
|
||||
// sensitive setup (nvm, PATH prepends) twice on templates that wire
|
||||
// .bash_profile -> .bashrc.
|
||||
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
|
||||
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
||||
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
|
||||
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
|
||||
execLine,
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
async function killSandboxBestEffort(sandbox: Sandbox, reason: string): Promise<void> {
|
||||
@@ -344,79 +391,72 @@ const plugin = definePlugin({
|
||||
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
||||
const command = buildCommandLine(params.command, params.args);
|
||||
if (params.stdin == null) {
|
||||
const baseCommand = buildLoginShellScript({
|
||||
command: params.command,
|
||||
args: params.args ?? [],
|
||||
env: params.env,
|
||||
});
|
||||
const timeoutMs = params.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
// For commands with stdin, stage the payload to a temp file inside the
|
||||
// sandbox and shell-redirect it. Streaming stdin via `sendStdin` raced
|
||||
// with fast-failing commands (the process exits before the RPC lands),
|
||||
// and the previous code awaited a foreground `run` before sending stdin
|
||||
// at all, so the data was never delivered. The staged-file approach
|
||||
// keeps execution synchronous, avoids the race, and is unaffected by
|
||||
// whether the command exits in microseconds or minutes.
|
||||
let stagedStdinPath: string | null = null;
|
||||
if (params.stdin != null) {
|
||||
stagedStdinPath = `/tmp/paperclip-stdin-${randomUUID()}`;
|
||||
try {
|
||||
const result = await sandbox.commands.run(command, {
|
||||
cwd: params.cwd,
|
||||
envs: params.env,
|
||||
timeoutMs: params.timeoutMs ?? config.timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
await sandbox.files.write(stagedStdinPath, params.stdin);
|
||||
} catch (error) {
|
||||
if (error instanceof CommandExitError) {
|
||||
const commandError = error as CommandExitError;
|
||||
return {
|
||||
exitCode: commandError.exitCode,
|
||||
timedOut: false,
|
||||
stdout: commandError.stdout,
|
||||
stderr: commandError.stderr,
|
||||
};
|
||||
}
|
||||
if (error instanceof TimeoutError) {
|
||||
return buildTimeoutExecuteResult(error);
|
||||
}
|
||||
// Best-effort cleanup in case the write partially succeeded; ignore
|
||||
// remove failures so the original error is what propagates.
|
||||
await sandbox.files.remove(stagedStdinPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const started = await sandbox.commands.run(command, {
|
||||
stdin: true,
|
||||
cwd: params.cwd,
|
||||
envs: params.env,
|
||||
timeoutMs: params.timeoutMs ?? config.timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
pid: number;
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
const command = stagedStdinPath
|
||||
? `${baseCommand} < ${shellQuote(stagedStdinPath)}`
|
||||
: baseCommand;
|
||||
|
||||
try {
|
||||
try {
|
||||
await sandbox.commands.sendStdin(started.pid, params.stdin);
|
||||
} finally {
|
||||
await sandbox.commands.closeStdin(started.pid);
|
||||
}
|
||||
// Env is interpolated into the script via `exec env KEY=val …` after
|
||||
// profile sourcing so user-configured env wins over anything profiles
|
||||
// export. No need to pass `envs:` separately.
|
||||
const result = await sandbox.commands.run(command, {
|
||||
cwd: params.cwd,
|
||||
timeoutMs,
|
||||
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
return {
|
||||
exitCode: started.exitCode,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: false,
|
||||
stdout: started.stdout,
|
||||
stderr: started.stderr,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CommandExitError) {
|
||||
const commandError = error as CommandExitError;
|
||||
return {
|
||||
exitCode: commandError.exitCode,
|
||||
exitCode: error.exitCode,
|
||||
timedOut: false,
|
||||
stdout: commandError.stdout,
|
||||
stderr: commandError.stderr,
|
||||
stdout: error.stdout,
|
||||
stderr: error.stderr,
|
||||
};
|
||||
}
|
||||
if (error instanceof TimeoutError) {
|
||||
return buildTimeoutExecuteResult(error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (stagedStdinPath) {
|
||||
await sandbox.files.remove(stagedStdinPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
|
||||
| Import | Purpose |
|
||||
|--------|--------|
|
||||
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
|
||||
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
|
||||
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, `useHostNavigation`, slot prop types |
|
||||
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
|
||||
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
||||
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
|
||||
@@ -47,7 +47,7 @@ The SDK is stable enough for local development and first-party examples, but the
|
||||
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
|
||||
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
|
||||
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
|
||||
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
|
||||
- The host ships a small shared React component kit through `@paperclipai/plugin-sdk/ui`. Use it for native Paperclip controls; custom React and CSS are still supported.
|
||||
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
|
||||
|
||||
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
|
||||
@@ -100,12 +100,14 @@ runWorker(plugin, import.meta.url);
|
||||
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
||||
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
||||
|
||||
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||
|
||||
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
||||
|
||||
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
|
||||
|
||||
**Trusted local folders:** Declare `manifest.localFolders[]` and the `local.folders` capability when a plugin needs an operator-configured company-scoped folder. Use `ctx.localFolders.configure()`, `status()`, `readText()`, and `writeTextAtomic()` instead of resolving arbitrary filesystem paths yourself. The host validates absolute roots, read/write access, required relative folders/files, traversal attempts, symlink escapes, and writes through temp-file-plus-rename atomic replacement.
|
||||
|
||||
## Events
|
||||
|
||||
Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
|
||||
@@ -201,12 +203,13 @@ Slots are mount points for plugin React components. Launchers are host-rendered
|
||||
|
||||
### Slot types / launcher placement zones
|
||||
|
||||
The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
|
||||
Slot types describe where a component mounts. Most values also exist as launcher placement zones.
|
||||
|
||||
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|
||||
|----------------------------|-------|---------------------------------------|
|
||||
| `page` | Global | — |
|
||||
| `sidebar` | Global | — |
|
||||
| `routeSidebar` | Global | — |
|
||||
| `sidebarPanel` | Global | — |
|
||||
| `settingsPage` | Global | — |
|
||||
| `dashboardWidget` | Global | — |
|
||||
@@ -233,6 +236,10 @@ A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plu
|
||||
|
||||
Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `routeSidebar`
|
||||
|
||||
Replaces the normal company sidebar while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation while keeping the company rail and account footer. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `sidebarPanel`
|
||||
|
||||
Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
|
||||
@@ -338,6 +345,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||
| | `http.outbound` |
|
||||
| | `secrets.read-ref` |
|
||||
| | `environment.drivers.register` |
|
||||
| | `local.folders` |
|
||||
| **Agent** | `agent.tools.register` |
|
||||
| | `agents.invoke` |
|
||||
| | `agent.sessions.create` |
|
||||
@@ -372,6 +380,38 @@ only inside the plugin namespace. Runtime `ctx.db.query()` allows `SELECT` from
|
||||
`ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the
|
||||
plugin namespace.
|
||||
|
||||
### Trusted Local Folders
|
||||
|
||||
Trusted local plugins can request operator-configured folders per company:
|
||||
|
||||
```ts
|
||||
export const manifest = {
|
||||
// ...
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "pages"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The host stores the selected path in company-scoped plugin settings and exposes
|
||||
readiness through:
|
||||
|
||||
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders`
|
||||
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status`
|
||||
- `POST /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate`
|
||||
- `PUT /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey`
|
||||
|
||||
Worker code should access files through `ctx.localFolders.readText()` and
|
||||
`ctx.localFolders.writeTextAtomic()`. Relative paths must stay inside the
|
||||
configured root; symlinks that escape the root are rejected.
|
||||
|
||||
### Scoped API Routes
|
||||
|
||||
Manifest-declared `apiRoutes` expose JSON routes under
|
||||
@@ -599,6 +639,23 @@ export function IssueLinearLink({ context }: PluginDetailTabProps) {
|
||||
}
|
||||
```
|
||||
|
||||
#### `useHostNavigation()`
|
||||
|
||||
Routes Paperclip-internal plugin links through the host router without a full document reload. Use `linkProps()` for anchors so the browser still gets a real `href` for copy-link, modifier-click, middle-click, and open-in-new-tab behavior.
|
||||
|
||||
```tsx
|
||||
import { useHostNavigation } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function WikiSidebarLink() {
|
||||
const hostNavigation = useHostNavigation();
|
||||
return <a {...hostNavigation.linkProps("/wiki")}>Wiki</a>;
|
||||
}
|
||||
```
|
||||
|
||||
`linkProps("/wiki")` resolves against the active company prefix, so in company `PAP` it renders `href="/PAP/wiki"`. Already-prefixed paths such as `/PAP/wiki` are not prefixed again. For button-style commands, call `hostNavigation.navigate("/issues/PAP-123")`.
|
||||
|
||||
Avoid raw same-origin `href`s or `window.location.assign()` for Paperclip-internal navigation from plugin UI. Those bypass the host router and can reload the whole app. External links should keep normal anchors with `target="_blank"` and `rel="noopener noreferrer"` as appropriate.
|
||||
|
||||
#### `usePluginStream<T>(channel, options?)`
|
||||
|
||||
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
|
||||
@@ -629,7 +686,118 @@ The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?co
|
||||
|
||||
### UI authoring note
|
||||
|
||||
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
|
||||
The host provides selected shared UI components through `@paperclipai/plugin-sdk/ui`.
|
||||
Plugins can also use normal React components, their own CSS, or small design
|
||||
primitives inside the plugin package.
|
||||
|
||||
Use the shared components when the plugin needs to look and behave like a native
|
||||
Paperclip surface:
|
||||
|
||||
| Component | Use when |
|
||||
|---|---|
|
||||
| `MarkdownBlock` | Rendering markdown from plugin or host data |
|
||||
| `MarkdownEditor` | Editing markdown with the host editor treatment |
|
||||
| `FileTree` | Showing serializable workspace/wiki/import paths |
|
||||
| `IssuesList` | Embedding a company-scoped native issue list |
|
||||
| `AssigneePicker` | Selecting an agent or board user with the same picker as the new issue pane |
|
||||
| `ProjectPicker` | Selecting a project with the same picker as the new issue pane |
|
||||
| `ManagedRoutinesList` | Showing plugin-managed routines in settings UI |
|
||||
|
||||
#### Shared Markdown Components
|
||||
|
||||
Plugin UI can render markdown and edit markdown using the same host components
|
||||
used by Paperclip issue comments and documents:
|
||||
|
||||
```tsx
|
||||
import { MarkdownBlock, MarkdownEditor } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function WikiPageEditor() {
|
||||
const [body, setBody] = useState("# Wiki page");
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarkdownBlock content={body} />
|
||||
<MarkdownEditor value={body} onChange={setBody} bordered />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`MarkdownBlock` can opt into Obsidian-style wikilinks when a plugin owns the
|
||||
target URL shape:
|
||||
|
||||
```tsx
|
||||
<MarkdownBlock
|
||||
content={"See [[wiki/entities/paperclip|Paperclip]]."}
|
||||
enableWikiLinks
|
||||
wikiLinkRoot="/wiki/page"
|
||||
/>
|
||||
```
|
||||
|
||||
#### Shared FileTree
|
||||
|
||||
Plugin UI can render the host file tree without importing host internals:
|
||||
|
||||
```tsx
|
||||
import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const nodes: FileTreeNode[] = [
|
||||
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
|
||||
{
|
||||
name: "wiki",
|
||||
path: "wiki",
|
||||
kind: "dir",
|
||||
children: [
|
||||
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function WikiFiles() {
|
||||
return (
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
expandedPaths={["wiki"]}
|
||||
selectedFile="wiki/index.md"
|
||||
onToggleDir={(path) => console.log("toggle", path)}
|
||||
onSelectFile={(path) => console.log("select", path)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Shared Assignee and Project Pickers
|
||||
|
||||
Use `AssigneePicker` and `ProjectPicker` when a plugin needs to create, filter,
|
||||
or configure work against Paperclip entities. Both are controlled components and
|
||||
load their options from the host for the provided company.
|
||||
|
||||
```tsx
|
||||
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function AssignmentControls({ companyId }: { companyId: string }) {
|
||||
const [assignee, setAssignee] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssigneePicker
|
||||
companyId={companyId}
|
||||
value={assignee}
|
||||
onChange={(value, selection) => {
|
||||
setAssignee(value);
|
||||
console.log(selection.assigneeAgentId, selection.assigneeUserId);
|
||||
}}
|
||||
/>
|
||||
<ProjectPicker
|
||||
companyId={companyId}
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Slot component props
|
||||
|
||||
@@ -639,6 +807,7 @@ Each slot type receives a typed props object with `context: PluginHostContext`.
|
||||
|-----------|----------------|------------------|
|
||||
| `page` | `PluginPageProps` | — |
|
||||
| `sidebar` | `PluginSidebarProps` | — |
|
||||
| `routeSidebar` | `PluginRouteSidebarProps` | — |
|
||||
| `settingsPage` | `PluginSettingsPageProps` | — |
|
||||
| `dashboardWidget` | `PluginWidgetProps` | — |
|
||||
| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
|
||||
@@ -741,14 +910,17 @@ Plugins can add a link under each project in the sidebar via the `projectSidebar
|
||||
Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
|
||||
|
||||
```tsx
|
||||
import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import {
|
||||
useHostNavigation,
|
||||
type PluginProjectSidebarItemProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||
const hostNavigation = useHostNavigation();
|
||||
const projectId = context.entityId;
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectRef = projectId; // or resolve from host; entityId is project id
|
||||
return (
|
||||
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
|
||||
<a {...hostNavigation.linkProps(`/projects/${projectRef}?tab=plugin:your-plugin:files`)}>
|
||||
Files
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -89,11 +89,12 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
||||
const esbuildManifest: EsbuildLikeOptions = {
|
||||
entryPoints: [manifestEntry],
|
||||
outdir,
|
||||
bundle: false,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
external: ["@paperclipai/plugin-sdk"],
|
||||
};
|
||||
|
||||
const esbuildUi = uiEntry
|
||||
|
||||
@@ -90,6 +90,16 @@ export interface HostServices {
|
||||
get(): Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Provides trusted company-scoped local folder helpers. */
|
||||
localFolders: {
|
||||
declarations(params: WorkerToHostMethods["localFolders.declarations"][0]): Promise<WorkerToHostMethods["localFolders.declarations"][1]>;
|
||||
configure(params: WorkerToHostMethods["localFolders.configure"][0]): Promise<WorkerToHostMethods["localFolders.configure"][1]>;
|
||||
status(params: WorkerToHostMethods["localFolders.status"][0]): Promise<WorkerToHostMethods["localFolders.status"][1]>;
|
||||
list(params: WorkerToHostMethods["localFolders.list"][0]): Promise<WorkerToHostMethods["localFolders.list"][1]>;
|
||||
readText(params: WorkerToHostMethods["localFolders.readText"][0]): Promise<WorkerToHostMethods["localFolders.readText"][1]>;
|
||||
writeTextAtomic(params: WorkerToHostMethods["localFolders.writeTextAtomic"][0]): Promise<WorkerToHostMethods["localFolders.writeTextAtomic"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `state.get`, `state.set`, `state.delete`. */
|
||||
state: {
|
||||
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
|
||||
@@ -165,6 +175,18 @@ export interface HostServices {
|
||||
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
||||
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
||||
getManaged(params: WorkerToHostMethods["projects.managed.get"][0]): Promise<WorkerToHostMethods["projects.managed.get"][1]>;
|
||||
reconcileManaged(params: WorkerToHostMethods["projects.managed.reconcile"][0]): Promise<WorkerToHostMethods["projects.managed.reconcile"][1]>;
|
||||
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `routines.managed.*`. */
|
||||
routines: {
|
||||
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>;
|
||||
managedReconcile(params: WorkerToHostMethods["routines.managed.reconcile"][0]): Promise<WorkerToHostMethods["routines.managed.reconcile"][1]>;
|
||||
managedReset(params: WorkerToHostMethods["routines.managed.reset"][0]): Promise<WorkerToHostMethods["routines.managed.reset"][1]>;
|
||||
managedUpdate(params: WorkerToHostMethods["routines.managed.update"][0]): Promise<WorkerToHostMethods["routines.managed.update"][1]>;
|
||||
managedRun(params: WorkerToHostMethods["routines.managed.run"][0]): Promise<WorkerToHostMethods["routines.managed.run"][1]>;
|
||||
};
|
||||
|
||||
/** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */
|
||||
@@ -202,6 +224,9 @@ export interface HostServices {
|
||||
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
|
||||
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
|
||||
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
|
||||
managedGet(params: WorkerToHostMethods["agents.managed.get"][0]): Promise<WorkerToHostMethods["agents.managed.get"][1]>;
|
||||
managedReconcile(params: WorkerToHostMethods["agents.managed.reconcile"][0]): Promise<WorkerToHostMethods["agents.managed.reconcile"][1]>;
|
||||
managedReset(params: WorkerToHostMethods["agents.managed.reset"][0]): Promise<WorkerToHostMethods["agents.managed.reset"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
|
||||
@@ -281,6 +306,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
// Config — always allowed
|
||||
"config.get": null,
|
||||
|
||||
// Trusted local folders
|
||||
"localFolders.declarations": null,
|
||||
"localFolders.configure": "local.folders",
|
||||
"localFolders.status": "local.folders",
|
||||
"localFolders.list": "local.folders",
|
||||
"localFolders.readText": "local.folders",
|
||||
"localFolders.writeTextAtomic": "local.folders",
|
||||
|
||||
// State
|
||||
"state.get": "plugin.state.read",
|
||||
"state.set": "plugin.state.write",
|
||||
@@ -326,6 +359,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
"projects.managed.get": "projects.managed",
|
||||
"projects.managed.reconcile": "projects.managed",
|
||||
"projects.managed.reset": "projects.managed",
|
||||
"routines.managed.get": "routines.managed",
|
||||
"routines.managed.reconcile": "routines.managed",
|
||||
"routines.managed.reset": "routines.managed",
|
||||
"routines.managed.update": "routines.managed",
|
||||
"routines.managed.run": "routines.managed",
|
||||
|
||||
// Issues
|
||||
"issues.list": "issues.read",
|
||||
@@ -357,6 +398,9 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"agents.pause": "agents.pause",
|
||||
"agents.resume": "agents.resume",
|
||||
"agents.invoke": "agents.invoke",
|
||||
"agents.managed.get": "agents.managed",
|
||||
"agents.managed.reconcile": "agents.managed",
|
||||
"agents.managed.reset": "agents.managed",
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": "agent.sessions.create",
|
||||
@@ -439,6 +483,25 @@ export function createHostClientHandlers(
|
||||
return services.config.get();
|
||||
}),
|
||||
|
||||
"localFolders.declarations": gated("localFolders.declarations", async (params) => {
|
||||
return services.localFolders.declarations(params);
|
||||
}),
|
||||
"localFolders.configure": gated("localFolders.configure", async (params) => {
|
||||
return services.localFolders.configure(params);
|
||||
}),
|
||||
"localFolders.status": gated("localFolders.status", async (params) => {
|
||||
return services.localFolders.status(params);
|
||||
}),
|
||||
"localFolders.list": gated("localFolders.list", async (params) => {
|
||||
return services.localFolders.list(params);
|
||||
}),
|
||||
"localFolders.readText": gated("localFolders.readText", async (params) => {
|
||||
return services.localFolders.readText(params);
|
||||
}),
|
||||
"localFolders.writeTextAtomic": gated("localFolders.writeTextAtomic", async (params) => {
|
||||
return services.localFolders.writeTextAtomic(params);
|
||||
}),
|
||||
|
||||
// State
|
||||
"state.get": gated("state.get", async (params) => {
|
||||
return services.state.get(params);
|
||||
@@ -530,6 +593,32 @@ export function createHostClientHandlers(
|
||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||
return services.projects.getWorkspaceForIssue(params);
|
||||
}),
|
||||
"projects.managed.get": gated("projects.managed.get", async (params) => {
|
||||
return services.projects.getManaged(params);
|
||||
}),
|
||||
"projects.managed.reconcile": gated("projects.managed.reconcile", async (params) => {
|
||||
return services.projects.reconcileManaged(params);
|
||||
}),
|
||||
"projects.managed.reset": gated("projects.managed.reset", async (params) => {
|
||||
return services.projects.resetManaged(params);
|
||||
}),
|
||||
|
||||
// Routines
|
||||
"routines.managed.get": gated("routines.managed.get", async (params) => {
|
||||
return services.routines.managedGet(params);
|
||||
}),
|
||||
"routines.managed.reconcile": gated("routines.managed.reconcile", async (params) => {
|
||||
return services.routines.managedReconcile(params);
|
||||
}),
|
||||
"routines.managed.reset": gated("routines.managed.reset", async (params) => {
|
||||
return services.routines.managedReset(params);
|
||||
}),
|
||||
"routines.managed.update": gated("routines.managed.update", async (params) => {
|
||||
return services.routines.managedUpdate(params);
|
||||
}),
|
||||
"routines.managed.run": gated("routines.managed.run", async (params) => {
|
||||
return services.routines.managedRun(params);
|
||||
}),
|
||||
|
||||
// Issues
|
||||
"issues.list": gated("issues.list", async (params) => {
|
||||
@@ -611,6 +700,15 @@ export function createHostClientHandlers(
|
||||
"agents.invoke": gated("agents.invoke", async (params) => {
|
||||
return services.agents.invoke(params);
|
||||
}),
|
||||
"agents.managed.get": gated("agents.managed.get", async (params) => {
|
||||
return services.agents.managedGet(params);
|
||||
}),
|
||||
"agents.managed.reconcile": gated("agents.managed.reconcile", async (params) => {
|
||||
return services.agents.managedReconcile(params);
|
||||
}),
|
||||
"agents.managed.reset": gated("agents.managed.reset", async (params) => {
|
||||
return services.agents.managedReset(params);
|
||||
}),
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
|
||||
|
||||
@@ -180,6 +180,13 @@ export type {
|
||||
export type {
|
||||
PluginContext,
|
||||
PluginConfigClient,
|
||||
PluginLocalFolderProblem,
|
||||
PluginLocalFolderStatus,
|
||||
PluginLocalFolderConfigureInput,
|
||||
PluginLocalFolderListOptions,
|
||||
PluginLocalFolderEntry,
|
||||
PluginLocalFolderListing,
|
||||
PluginLocalFoldersClient,
|
||||
PluginEventsClient,
|
||||
PluginJobsClient,
|
||||
PluginLaunchersClient,
|
||||
@@ -255,6 +262,14 @@ export type {
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectDeclaration,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineDeclaration,
|
||||
PluginManagedRoutineResolution,
|
||||
PluginManagedResourceKind,
|
||||
PluginManagedResourceRef,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginUiDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
@@ -264,6 +279,8 @@ export type {
|
||||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginApiRouteDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginCompanySettings,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
PluginMigrationRecord,
|
||||
|
||||
@@ -29,8 +29,14 @@ import type {
|
||||
IssueDocumentSummary,
|
||||
IssueThreadInteraction,
|
||||
CreateIssueThreadInteraction,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineResolution,
|
||||
Routine,
|
||||
RoutineRun,
|
||||
Agent,
|
||||
Goal,
|
||||
PluginLocalFolderDeclaration,
|
||||
} from "@paperclipai/shared";
|
||||
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
||||
|
||||
@@ -46,6 +52,8 @@ import type {
|
||||
PluginWorkspace,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
PluginLocalFolderListing,
|
||||
PluginLocalFolderStatus,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginHealthDiagnostics,
|
||||
@@ -566,6 +574,44 @@ export interface WorkerToHostMethods {
|
||||
// Config
|
||||
"config.get": [params: Record<string, never>, result: Record<string, unknown>];
|
||||
|
||||
// Trusted local folders
|
||||
"localFolders.declarations": [
|
||||
params: Record<string, never>,
|
||||
result: PluginLocalFolderDeclaration[],
|
||||
];
|
||||
"localFolders.configure": [
|
||||
params: {
|
||||
companyId: string;
|
||||
folderKey: string;
|
||||
path: string;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
},
|
||||
result: PluginLocalFolderStatus,
|
||||
];
|
||||
"localFolders.status": [
|
||||
params: { companyId: string; folderKey: string },
|
||||
result: PluginLocalFolderStatus,
|
||||
];
|
||||
"localFolders.list": [
|
||||
params: { companyId: string; folderKey: string; relativePath?: string | null; recursive?: boolean; maxEntries?: number },
|
||||
result: PluginLocalFolderListing,
|
||||
];
|
||||
"localFolders.readText": [
|
||||
params: { companyId: string; folderKey: string; relativePath: string },
|
||||
result: string,
|
||||
];
|
||||
"localFolders.writeTextAtomic": [
|
||||
params: {
|
||||
companyId: string;
|
||||
folderKey: string;
|
||||
relativePath: string;
|
||||
contents: string;
|
||||
},
|
||||
result: PluginLocalFolderStatus,
|
||||
];
|
||||
|
||||
// State
|
||||
"state.get": [
|
||||
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
|
||||
@@ -724,6 +770,57 @@ export interface WorkerToHostMethods {
|
||||
params: { issueId: string; companyId: string },
|
||||
result: PluginWorkspace | null,
|
||||
];
|
||||
"projects.managed.get": [
|
||||
params: { projectKey: string; companyId: string },
|
||||
result: PluginManagedProjectResolution,
|
||||
];
|
||||
"projects.managed.reconcile": [
|
||||
params: { projectKey: string; companyId: string },
|
||||
result: PluginManagedProjectResolution,
|
||||
];
|
||||
"projects.managed.reset": [
|
||||
params: { projectKey: string; companyId: string },
|
||||
result: PluginManagedProjectResolution,
|
||||
];
|
||||
"routines.managed.get": [
|
||||
params: { routineKey: string; companyId: string },
|
||||
result: PluginManagedRoutineResolution,
|
||||
];
|
||||
"routines.managed.reconcile": [
|
||||
params: {
|
||||
routineKey: string;
|
||||
companyId: string;
|
||||
assigneeAgentId?: string | null;
|
||||
projectId?: string | null;
|
||||
},
|
||||
result: PluginManagedRoutineResolution,
|
||||
];
|
||||
"routines.managed.reset": [
|
||||
params: {
|
||||
routineKey: string;
|
||||
companyId: string;
|
||||
assigneeAgentId?: string | null;
|
||||
projectId?: string | null;
|
||||
},
|
||||
result: PluginManagedRoutineResolution,
|
||||
];
|
||||
"routines.managed.update": [
|
||||
params: {
|
||||
routineKey: string;
|
||||
companyId: string;
|
||||
status?: string;
|
||||
},
|
||||
result: Routine,
|
||||
];
|
||||
"routines.managed.run": [
|
||||
params: {
|
||||
routineKey: string;
|
||||
companyId: string;
|
||||
assigneeAgentId?: string | null;
|
||||
projectId?: string | null;
|
||||
},
|
||||
result: RoutineRun,
|
||||
];
|
||||
|
||||
// Issues
|
||||
"issues.list": [
|
||||
@@ -732,8 +829,10 @@ export interface WorkerToHostMethods {
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
originKind?: string;
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
status?: string;
|
||||
includePluginOperations?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
@@ -758,6 +857,7 @@ export interface WorkerToHostMethods {
|
||||
assigneeUserId?: string | null;
|
||||
requestDepth?: number;
|
||||
billingCode?: string | null;
|
||||
surfaceVisibility?: string | null;
|
||||
originKind?: string | null;
|
||||
originId?: string | null;
|
||||
originRunId?: string | null;
|
||||
@@ -940,6 +1040,18 @@ export interface WorkerToHostMethods {
|
||||
params: { agentId: string; companyId: string; prompt: string; reason?: string },
|
||||
result: { runId: string },
|
||||
];
|
||||
"agents.managed.get": [
|
||||
params: { agentKey: string; companyId: string },
|
||||
result: PluginManagedAgentResolution,
|
||||
];
|
||||
"agents.managed.reconcile": [
|
||||
params: { agentKey: string; companyId: string },
|
||||
result: PluginManagedAgentResolution,
|
||||
];
|
||||
"agents.managed.reset": [
|
||||
params: { agentKey: string; companyId: string },
|
||||
result: PluginManagedAgentResolution,
|
||||
];
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": [
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginCapability,
|
||||
PluginEventType,
|
||||
PluginIssueOriginKind,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedRoutineResolution,
|
||||
Company,
|
||||
Project,
|
||||
Routine,
|
||||
RoutineRun,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueThreadInteraction,
|
||||
@@ -419,6 +424,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const entityExternalIndex = new Map<string, string>();
|
||||
const companies = new Map<string, Company>();
|
||||
const projects = new Map<string, Project>();
|
||||
const routines = new Map<string, Routine>();
|
||||
const routineRuns = new Map<string, RoutineRun>();
|
||||
const issues = new Map<string, Issue>();
|
||||
const blockedByIssueIds = new Map<string, string[]>();
|
||||
const issueComments = new Map<string, IssueComment[]>();
|
||||
@@ -465,6 +472,53 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
}
|
||||
|
||||
const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`;
|
||||
|
||||
function managedAgentDeclaration(agentKey: string) {
|
||||
const declaration = manifest.agents?.find((agent) => agent.agentKey === agentKey);
|
||||
if (!declaration) throw new Error(`Managed agent declaration not found: ${agentKey}`);
|
||||
return declaration;
|
||||
}
|
||||
|
||||
function isManagedAgent(agent: Agent, agentKey: string) {
|
||||
const marker = agent.metadata?.paperclipManagedResource;
|
||||
return Boolean(
|
||||
marker
|
||||
&& typeof marker === "object"
|
||||
&& !Array.isArray(marker)
|
||||
&& (marker as Record<string, unknown>).pluginKey === manifest.id
|
||||
&& (marker as Record<string, unknown>).resourceKind === "agent"
|
||||
&& (marker as Record<string, unknown>).resourceKey === agentKey,
|
||||
);
|
||||
}
|
||||
|
||||
function managedAgentMetadata(agentKey: string, existing?: Record<string, unknown> | null) {
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
paperclipManagedResource: {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "agent",
|
||||
resourceKey: agentKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function managedResolution(
|
||||
agentKey: string,
|
||||
companyId: string,
|
||||
agent: Agent | null,
|
||||
status: PluginManagedAgentResolution["status"],
|
||||
): PluginManagedAgentResolution {
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "agent",
|
||||
resourceKey: agentKey,
|
||||
companyId,
|
||||
agentId: agent?.id ?? null,
|
||||
agent,
|
||||
status,
|
||||
approvalId: null,
|
||||
};
|
||||
}
|
||||
function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind {
|
||||
if (originKind == null || originKind === "") return defaultPluginOriginKind;
|
||||
if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string");
|
||||
@@ -481,6 +535,81 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
return { ...currentConfig };
|
||||
},
|
||||
},
|
||||
localFolders: {
|
||||
declarations() {
|
||||
return manifest.localFolders ?? [];
|
||||
},
|
||||
async configure(input) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
folderKey: input.folderKey,
|
||||
configured: true,
|
||||
path: input.path,
|
||||
realPath: input.path,
|
||||
access: input.access ?? "readWrite",
|
||||
readable: true,
|
||||
writable: input.access === "read" ? false : true,
|
||||
requiredDirectories: input.requiredDirectories ?? [],
|
||||
requiredFiles: input.requiredFiles ?? [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: true,
|
||||
problems: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
async status(_companyId, folderKey) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
folderKey,
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
access: "readWrite",
|
||||
readable: false,
|
||||
writable: false,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: false,
|
||||
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
async list(_companyId, folderKey, options) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
folderKey,
|
||||
relativePath: options?.relativePath ?? null,
|
||||
entries: [],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
async readText() {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
throw new Error("Test harness local folder readText is not implemented");
|
||||
},
|
||||
async writeTextAtomic(_companyId, folderKey) {
|
||||
requireCapability(manifest, capabilitySet, "local.folders");
|
||||
return {
|
||||
folderKey,
|
||||
configured: false,
|
||||
path: null,
|
||||
realPath: null,
|
||||
access: "readWrite",
|
||||
readable: false,
|
||||
writable: false,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: false,
|
||||
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
},
|
||||
events: {
|
||||
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||
@@ -647,6 +776,316 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
managed: {
|
||||
async get(projectKey, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "projects.managed");
|
||||
const declaration = manifest.projects?.find((project) => project.projectKey === projectKey);
|
||||
if (!declaration) {
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "project",
|
||||
resourceKey: projectKey,
|
||||
companyId,
|
||||
projectId: null,
|
||||
project: null,
|
||||
status: "missing",
|
||||
};
|
||||
}
|
||||
const externalId = `${manifest.id}:project:${projectKey}`;
|
||||
const existingEntity = [...entities.values()].find((entity) =>
|
||||
entity.entityType === "managed_resource"
|
||||
&& entity.scopeKind === "company"
|
||||
&& entity.scopeId === companyId
|
||||
&& entity.externalId === externalId
|
||||
);
|
||||
const existingProject = existingEntity ? projects.get(String(existingEntity.data?.projectId ?? "")) : null;
|
||||
if (existingProject && isInCompany(existingProject, companyId)) {
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "project",
|
||||
resourceKey: projectKey,
|
||||
companyId,
|
||||
projectId: existingProject.id,
|
||||
project: existingProject,
|
||||
status: "resolved",
|
||||
};
|
||||
}
|
||||
const now = new Date();
|
||||
const project = {
|
||||
id: `project-${projects.size + 1}`,
|
||||
companyId,
|
||||
urlKey: declaration.projectKey,
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: declaration.displayName,
|
||||
description: declaration.description ?? null,
|
||||
status: declaration.status ?? "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: declaration.color ?? null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: `/tmp/${declaration.projectKey}`,
|
||||
effectiveLocalFolder: `/tmp/${declaration.projectKey}`,
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
managedByPlugin: {
|
||||
id: `managed-${projects.size + 1}`,
|
||||
pluginId: manifest.id,
|
||||
pluginKey: manifest.id,
|
||||
pluginDisplayName: manifest.displayName,
|
||||
resourceKind: "project",
|
||||
resourceKey: projectKey,
|
||||
defaultsJson: { displayName: declaration.displayName, settings: declaration.settings ?? {} },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as Project;
|
||||
projects.set(project.id, project);
|
||||
const externalKey = `managed_resource|company|${companyId}|${externalId}`;
|
||||
const nowIso = now.toISOString();
|
||||
const record: PluginEntityRecord = {
|
||||
id: randomUUID(),
|
||||
entityType: "managed_resource",
|
||||
scopeKind: "company",
|
||||
scopeId: companyId,
|
||||
externalId,
|
||||
title: declaration.displayName,
|
||||
status: null,
|
||||
data: { resourceKind: "project", resourceKey: projectKey, projectId: project.id },
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
entities.set(record.id, record);
|
||||
entityExternalIndex.set(externalKey, record.id);
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "project",
|
||||
resourceKey: projectKey,
|
||||
companyId,
|
||||
projectId: project.id,
|
||||
project,
|
||||
status: "created",
|
||||
};
|
||||
},
|
||||
async reconcile(projectKey, companyId) {
|
||||
return this.get(projectKey, companyId);
|
||||
},
|
||||
async reset(projectKey, companyId) {
|
||||
const resolved = await this.get(projectKey, companyId);
|
||||
return { ...resolved, status: resolved.project ? "reset" : resolved.status };
|
||||
},
|
||||
},
|
||||
},
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "routines.managed");
|
||||
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
|
||||
if (!declaration) {
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "routine",
|
||||
resourceKey: routineKey,
|
||||
companyId,
|
||||
routineId: null,
|
||||
routine: null,
|
||||
status: "missing",
|
||||
missingRefs: [],
|
||||
} satisfies PluginManagedRoutineResolution;
|
||||
}
|
||||
const externalId = `${manifest.id}:routine:${routineKey}`;
|
||||
const existingEntity = [...entities.values()].find((entity) =>
|
||||
entity.entityType === "managed_resource"
|
||||
&& entity.scopeKind === "company"
|
||||
&& entity.scopeId === companyId
|
||||
&& entity.externalId === externalId
|
||||
);
|
||||
const existingRoutine = existingEntity ? routines.get(String(existingEntity.data?.routineId ?? "")) : null;
|
||||
if (existingRoutine && isInCompany(existingRoutine, companyId)) {
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "routine",
|
||||
resourceKey: routineKey,
|
||||
companyId,
|
||||
routineId: existingRoutine.id,
|
||||
routine: existingRoutine,
|
||||
status: "resolved",
|
||||
missingRefs: [],
|
||||
} satisfies PluginManagedRoutineResolution;
|
||||
}
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "routine",
|
||||
resourceKey: routineKey,
|
||||
companyId,
|
||||
routineId: null,
|
||||
routine: null,
|
||||
status: "missing",
|
||||
missingRefs: [],
|
||||
} satisfies PluginManagedRoutineResolution;
|
||||
},
|
||||
async reconcile(routineKey, companyId, overrides) {
|
||||
const existing = await this.get(routineKey, companyId);
|
||||
if (existing.routine) return existing;
|
||||
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
|
||||
if (!declaration) return existing;
|
||||
const now = new Date();
|
||||
const agentRef = declaration.assigneeRef;
|
||||
const projectRef = declaration.projectRef;
|
||||
const assigneeAgentId = overrides?.assigneeAgentId
|
||||
?? (agentRef?.resourceKind === "agent"
|
||||
? [...agents.values()].find((agent) => isInCompany(agent, companyId) && isManagedAgent(agent, agentRef.resourceKey))?.id
|
||||
: null)
|
||||
?? null;
|
||||
const projectId = overrides?.projectId
|
||||
?? (projectRef?.resourceKind === "project"
|
||||
? [...projects.values()].find((project) => (
|
||||
isInCompany(project, companyId)
|
||||
&& project.managedByPlugin?.pluginKey === manifest.id
|
||||
&& project.managedByPlugin?.resourceKey === projectRef.resourceKey
|
||||
))?.id
|
||||
: null)
|
||||
?? null;
|
||||
const missingRefs: NonNullable<PluginManagedRoutineResolution["missingRefs"]> = [];
|
||||
if (agentRef && !assigneeAgentId) missingRefs.push({ ...agentRef, pluginKey: manifest.id });
|
||||
if (projectRef && !projectId) missingRefs.push({ ...projectRef, pluginKey: manifest.id });
|
||||
if (missingRefs.length > 0) {
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "routine",
|
||||
resourceKey: routineKey,
|
||||
companyId,
|
||||
routineId: null,
|
||||
routine: null,
|
||||
status: "missing_refs",
|
||||
missingRefs,
|
||||
} satisfies PluginManagedRoutineResolution;
|
||||
}
|
||||
const routine = {
|
||||
id: `routine-${routines.size + 1}`,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId: declaration.goalId ?? null,
|
||||
parentIssueId: null,
|
||||
title: declaration.title,
|
||||
description: declaration.description ?? null,
|
||||
assigneeAgentId,
|
||||
priority: declaration.priority ?? "medium",
|
||||
status: declaration.status ?? (assigneeAgentId ? "active" : "paused"),
|
||||
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
|
||||
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
|
||||
variables: declaration.variables ?? [],
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: null,
|
||||
lastTriggeredAt: null,
|
||||
lastEnqueuedAt: null,
|
||||
latestRevisionId: null,
|
||||
latestRevisionNumber: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
managedByPlugin: {
|
||||
id: `managed-routine-${routines.size + 1}`,
|
||||
pluginId: manifest.id,
|
||||
pluginKey: manifest.id,
|
||||
pluginDisplayName: manifest.displayName,
|
||||
resourceKind: "routine",
|
||||
resourceKey: routineKey,
|
||||
defaultsJson: { title: declaration.title, issueTemplate: declaration.issueTemplate ?? null },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
} as Routine;
|
||||
routines.set(routine.id, routine);
|
||||
const nowIso = now.toISOString();
|
||||
const record: PluginEntityRecord = {
|
||||
id: randomUUID(),
|
||||
entityType: "managed_resource",
|
||||
scopeKind: "company",
|
||||
scopeId: companyId,
|
||||
externalId: `${manifest.id}:routine:${routineKey}`,
|
||||
title: declaration.title,
|
||||
status: null,
|
||||
data: { resourceKind: "routine", resourceKey: routineKey, routineId: routine.id },
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
entities.set(record.id, record);
|
||||
return {
|
||||
pluginKey: manifest.id,
|
||||
resourceKind: "routine",
|
||||
resourceKey: routineKey,
|
||||
companyId,
|
||||
routineId: routine.id,
|
||||
routine,
|
||||
status: "created",
|
||||
missingRefs: [],
|
||||
} satisfies PluginManagedRoutineResolution;
|
||||
},
|
||||
async reset(routineKey, companyId, overrides) {
|
||||
const resolved = await this.reconcile(routineKey, companyId, overrides);
|
||||
return { ...resolved, status: resolved.routine ? "reset" : resolved.status } satisfies PluginManagedRoutineResolution;
|
||||
},
|
||||
async update(routineKey, companyId, patch) {
|
||||
const resolved = await this.get(routineKey, companyId);
|
||||
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
|
||||
const next = {
|
||||
...resolved.routine,
|
||||
...(patch.status !== undefined ? { status: patch.status } : {}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
routines.set(next.id, next);
|
||||
return next;
|
||||
},
|
||||
async run(routineKey, companyId) {
|
||||
const resolved = await this.get(routineKey, companyId);
|
||||
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
|
||||
const now = new Date();
|
||||
const run = {
|
||||
id: `routine-run-${routineRuns.size + 1}`,
|
||||
companyId,
|
||||
routineId: resolved.routine.id,
|
||||
triggerId: null,
|
||||
source: "manual",
|
||||
status: "queued",
|
||||
triggeredAt: now,
|
||||
idempotencyKey: null,
|
||||
triggerPayload: null,
|
||||
dispatchFingerprint: null,
|
||||
linkedIssueId: null,
|
||||
coalescedIntoRunId: null,
|
||||
failureReason: null,
|
||||
completedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies RoutineRun;
|
||||
routineRuns.set(run.id, run);
|
||||
routines.set(resolved.routine.id, {
|
||||
...resolved.routine,
|
||||
lastTriggeredAt: now,
|
||||
lastEnqueuedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return run;
|
||||
},
|
||||
},
|
||||
},
|
||||
companies: {
|
||||
async list(input) {
|
||||
@@ -673,6 +1112,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind);
|
||||
out = out.filter((issue) => issue.originKind === input.originKind);
|
||||
}
|
||||
if (input?.originKindPrefix) {
|
||||
const prefix = input.originKindPrefix;
|
||||
out = out.filter((issue) =>
|
||||
typeof issue.originKind === "string" && issue.originKind.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
if (input?.originId) out = out.filter((issue) => issue.originId === input.originId);
|
||||
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
@@ -687,6 +1132,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.create");
|
||||
const now = new Date();
|
||||
const originKind = normalizePluginOriginKind(
|
||||
input.surfaceVisibility === "plugin_operation" && !input.originKind
|
||||
? pluginOperationIssueOriginKind(manifest.id)
|
||||
: input.originKind,
|
||||
);
|
||||
const record: Issue = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
@@ -708,7 +1158,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
createdByUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
originKind: normalizePluginOriginKind(input.originKind),
|
||||
originKind,
|
||||
originId: input.originId ?? null,
|
||||
originRunId: input.originRunId ?? null,
|
||||
requestDepth: input.requestDepth ?? 0,
|
||||
@@ -1064,6 +1514,115 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
managed: {
|
||||
async get(agentKey, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.managed");
|
||||
const cid = requireCompanyId(companyId);
|
||||
managedAgentDeclaration(agentKey);
|
||||
const agent = [...agents.values()].find((candidate) =>
|
||||
candidate.companyId === cid &&
|
||||
candidate.status !== "terminated" &&
|
||||
isManagedAgent(candidate, agentKey),
|
||||
) ?? null;
|
||||
return managedResolution(agentKey, cid, agent, agent ? "resolved" : "missing");
|
||||
},
|
||||
async reconcile(agentKey, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.managed");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const declaration = managedAgentDeclaration(agentKey);
|
||||
const existingAgent = [...agents.values()].find((candidate) =>
|
||||
candidate.companyId === cid &&
|
||||
candidate.status !== "terminated" &&
|
||||
isManagedAgent(candidate, agentKey),
|
||||
) ?? null;
|
||||
const existing = managedResolution(agentKey, cid, existingAgent, existingAgent ? "resolved" : "missing");
|
||||
if (existing.agent) return existing;
|
||||
const now = new Date();
|
||||
const created: Agent = {
|
||||
id: randomUUID(),
|
||||
companyId: cid,
|
||||
name: declaration.displayName,
|
||||
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
||||
role: (declaration.role ?? "general") as Agent["role"],
|
||||
title: declaration.title ?? null,
|
||||
icon: declaration.icon ?? null,
|
||||
status: declaration.status ?? "idle",
|
||||
reportsTo: null,
|
||||
capabilities: declaration.capabilities ?? null,
|
||||
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
||||
adapterConfig: declaration.adapterConfig ?? {},
|
||||
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: managedAgentMetadata(agentKey),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
agents.set(created.id, created);
|
||||
return managedResolution(agentKey, cid, created, "created");
|
||||
},
|
||||
async reset(agentKey, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.managed");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const declaration = managedAgentDeclaration(agentKey);
|
||||
let agent = [...agents.values()].find((candidate) =>
|
||||
candidate.companyId === cid &&
|
||||
candidate.status !== "terminated" &&
|
||||
isManagedAgent(candidate, agentKey),
|
||||
) ?? null;
|
||||
if (!agent) {
|
||||
const now = new Date();
|
||||
agent = {
|
||||
id: randomUUID(),
|
||||
companyId: cid,
|
||||
name: declaration.displayName,
|
||||
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
||||
role: (declaration.role ?? "general") as Agent["role"],
|
||||
title: declaration.title ?? null,
|
||||
icon: declaration.icon ?? null,
|
||||
status: declaration.status ?? "idle",
|
||||
reportsTo: null,
|
||||
capabilities: declaration.capabilities ?? null,
|
||||
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
||||
adapterConfig: declaration.adapterConfig ?? {},
|
||||
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: managedAgentMetadata(agentKey),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
agents.set(agent.id, agent);
|
||||
}
|
||||
const resolved = managedResolution(agentKey, cid, agent, "resolved");
|
||||
if (!resolved.agent) return resolved;
|
||||
const updated: Agent = {
|
||||
...resolved.agent,
|
||||
name: declaration.displayName,
|
||||
role: (declaration.role ?? "general") as Agent["role"],
|
||||
title: declaration.title ?? null,
|
||||
icon: declaration.icon ?? null,
|
||||
capabilities: declaration.capabilities ?? null,
|
||||
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
||||
adapterConfig: declaration.adapterConfig ?? {},
|
||||
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
||||
metadata: managedAgentMetadata(agentKey, resolved.agent.metadata),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
agents.set(updated.id, updated);
|
||||
return managedResolution(agentKey, cid, updated, "reset");
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
async create(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
||||
|
||||
@@ -28,6 +28,12 @@ import type {
|
||||
RequestConfirmationInteraction,
|
||||
CreateIssueThreadInteraction,
|
||||
PluginIssueOriginKind,
|
||||
IssueSurfaceVisibility,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineResolution,
|
||||
Routine,
|
||||
RoutineRun,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -42,6 +48,18 @@ export type {
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectDeclaration,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineDeclaration,
|
||||
PluginManagedRoutineResolution,
|
||||
Routine,
|
||||
RoutineRun,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginCompanySettings,
|
||||
PluginManagedResourceKind,
|
||||
PluginManagedResourceRef,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginUiDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
@@ -92,6 +110,7 @@ export type {
|
||||
RequestConfirmationInteraction,
|
||||
CreateIssueThreadInteraction,
|
||||
PluginIssueOriginKind,
|
||||
IssueSurfaceVisibility,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -349,6 +368,90 @@ export interface PluginConfigClient {
|
||||
get(): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderProblem {
|
||||
code:
|
||||
| "not_configured"
|
||||
| "not_absolute"
|
||||
| "missing"
|
||||
| "not_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "missing_directory"
|
||||
| "missing_file"
|
||||
| "path_traversal"
|
||||
| "symlink_escape"
|
||||
| "atomic_write_failed";
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderStatus {
|
||||
folderKey: string;
|
||||
configured: boolean;
|
||||
path: string | null;
|
||||
realPath: string | null;
|
||||
access: "read" | "readWrite";
|
||||
readable: boolean;
|
||||
writable: boolean;
|
||||
requiredDirectories: string[];
|
||||
requiredFiles: string[];
|
||||
missingDirectories: string[];
|
||||
missingFiles: string[];
|
||||
healthy: boolean;
|
||||
problems: PluginLocalFolderProblem[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderConfigureInput {
|
||||
companyId: string;
|
||||
folderKey: string;
|
||||
path: string;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderListOptions {
|
||||
relativePath?: string | null;
|
||||
recursive?: boolean;
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
kind: "file" | "directory";
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderListing {
|
||||
folderKey: string;
|
||||
relativePath: string | null;
|
||||
entries: PluginLocalFolderEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface PluginLocalFoldersClient {
|
||||
/** Manifest-declared local folders for this plugin. */
|
||||
declarations(): import("@paperclipai/shared").PluginLocalFolderDeclaration[];
|
||||
/** Persist a company-scoped local folder path after validating it. */
|
||||
configure(input: PluginLocalFolderConfigureInput): Promise<PluginLocalFolderStatus>;
|
||||
/** Check the stored folder readiness for a company and folder key. */
|
||||
status(companyId: string, folderKey: string): Promise<PluginLocalFolderStatus>;
|
||||
/** List entries below a configured folder after containment checks. */
|
||||
list(companyId: string, folderKey: string, options?: PluginLocalFolderListOptions): Promise<PluginLocalFolderListing>;
|
||||
/** Read a UTF-8 text file below a configured folder after containment checks. */
|
||||
readText(companyId: string, folderKey: string, relativePath: string): Promise<string>;
|
||||
/** Write a UTF-8 text file below a configured folder using atomic rename. */
|
||||
writeTextAtomic(
|
||||
companyId: string,
|
||||
folderKey: string,
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
): Promise<PluginLocalFolderStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.events` — subscribe to and emit Paperclip domain events.
|
||||
*
|
||||
@@ -697,6 +800,44 @@ export interface PluginProjectsClient {
|
||||
* @see PLUGIN_SPEC.md §20 — Local Tooling
|
||||
*/
|
||||
getWorkspaceForIssue(issueId: string, companyId: string): Promise<PluginWorkspace | null>;
|
||||
|
||||
/** Resolve and reconcile manifest-declared plugin-managed projects by stable key. Requires `projects.managed`. */
|
||||
managed: {
|
||||
get(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
|
||||
reconcile(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
|
||||
reset(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
|
||||
*
|
||||
* Requires `routines.managed` capability.
|
||||
*/
|
||||
export interface PluginRoutinesClient {
|
||||
managed: {
|
||||
get(routineKey: string, companyId: string): Promise<PluginManagedRoutineResolution>;
|
||||
reconcile(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||
): Promise<PluginManagedRoutineResolution>;
|
||||
reset(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||
): Promise<PluginManagedRoutineResolution>;
|
||||
update(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
patch: { status?: string },
|
||||
): Promise<Routine>;
|
||||
run(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||
): Promise<RoutineRun>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1099,8 +1240,10 @@ export interface PluginIssuesClient {
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
originKind?: PluginIssueOriginKind;
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
status?: Issue["status"];
|
||||
includePluginOperations?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Issue[]>;
|
||||
@@ -1119,6 +1262,7 @@ export interface PluginIssuesClient {
|
||||
assigneeUserId?: string | null;
|
||||
requestDepth?: number;
|
||||
billingCode?: string | null;
|
||||
surfaceVisibility?: IssueSurfaceVisibility;
|
||||
originKind?: PluginIssueOriginKind;
|
||||
originId?: string | null;
|
||||
originRunId?: string | null;
|
||||
@@ -1241,6 +1385,12 @@ export interface PluginAgentsClient {
|
||||
resume(agentId: string, companyId: string): Promise<Agent>;
|
||||
/** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */
|
||||
invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>;
|
||||
/** Resolve and reconcile manifest-declared plugin-managed agents by stable key. Requires `agents.managed`. */
|
||||
managed: {
|
||||
get(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
|
||||
reconcile(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
|
||||
reset(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
|
||||
};
|
||||
/** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */
|
||||
sessions: PluginAgentSessionsClient;
|
||||
}
|
||||
@@ -1436,6 +1586,9 @@ export interface PluginContext {
|
||||
/** Read resolved operator configuration. */
|
||||
config: PluginConfigClient;
|
||||
|
||||
/** Configure and safely access trusted company-scoped local folders. */
|
||||
localFolders: PluginLocalFoldersClient;
|
||||
|
||||
/** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */
|
||||
events: PluginEventsClient;
|
||||
|
||||
@@ -1466,6 +1619,9 @@ export interface PluginContext {
|
||||
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
|
||||
projects: PluginProjectsClient;
|
||||
|
||||
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
|
||||
routines: PluginRoutinesClient;
|
||||
|
||||
/** Read company metadata. Requires `companies.read`. */
|
||||
companies: PluginCompaniesClient;
|
||||
|
||||
|
||||
@@ -125,6 +125,36 @@ export interface TimeseriesChartProps {
|
||||
export interface MarkdownBlockProps {
|
||||
/** Markdown content to render. */
|
||||
content: string;
|
||||
/** Optional CSS class name forwarded to the host renderer. */
|
||||
className?: string;
|
||||
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
|
||||
enableWikiLinks?: boolean;
|
||||
/** Base href used for wikilinks when no resolver is supplied. */
|
||||
wikiLinkRoot?: string;
|
||||
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
}
|
||||
|
||||
/** Props for `MarkdownEditor`. */
|
||||
export interface MarkdownEditorProps {
|
||||
/** Markdown source controlled by the plugin. */
|
||||
value: string;
|
||||
/** Called whenever the markdown source changes. */
|
||||
onChange: (value: string) => void;
|
||||
/** Placeholder text shown when the document is empty. */
|
||||
placeholder?: string;
|
||||
/** Optional wrapper CSS class name. */
|
||||
className?: string;
|
||||
/** Optional editable content CSS class name. */
|
||||
contentClassName?: string;
|
||||
/** Called when the editor loses focus. */
|
||||
onBlur?: () => void;
|
||||
/** Render the editor with a host border treatment. */
|
||||
bordered?: boolean;
|
||||
/** Render the rich editor without allowing edits. */
|
||||
readOnly?: boolean;
|
||||
/** Called on Cmd/Ctrl+Enter. */
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
/** A single key-value pair for `KeyValueList`. */
|
||||
@@ -217,6 +247,211 @@ export interface ErrorBoundaryProps {
|
||||
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
/** File or directory node rendered by `FileTree`. */
|
||||
export interface FileTreeNode {
|
||||
/** Display name for this path segment. */
|
||||
name: string;
|
||||
/** Slash-separated path relative to the tree root. */
|
||||
path: string;
|
||||
/** Whether this node is a directory or file. */
|
||||
kind: "dir" | "file";
|
||||
/** Child nodes. Files should use an empty array. */
|
||||
children: FileTreeNode[];
|
||||
/** Optional stable action metadata for host/plugin workflows. */
|
||||
action?: string | null;
|
||||
}
|
||||
|
||||
/** Badge status variants supported by `FileTree`. */
|
||||
export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||
|
||||
/** Serializable badge metadata keyed by file path. */
|
||||
export interface FileTreeBadge {
|
||||
label: string;
|
||||
status: FileTreeBadgeVariant;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/** Row tone variants supported by `FileTree`. */
|
||||
export type FileTreeTone = "default" | "warning" | "error" | "muted";
|
||||
|
||||
/** Empty-state content shown when a tree has no nodes. */
|
||||
export interface FileTreeEmptyState {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Error-state content shown when a tree cannot be loaded. */
|
||||
export interface FileTreeErrorState {
|
||||
message: string;
|
||||
retry?: () => void;
|
||||
}
|
||||
|
||||
/** Accepted path collection shape for expanded and checked file tree state. */
|
||||
export type FileTreePathCollection = ReadonlySet<string> | readonly string[];
|
||||
|
||||
/** Props for `FileTree`. */
|
||||
export interface FileTreeProps {
|
||||
/** Tree nodes to render. */
|
||||
nodes: FileTreeNode[];
|
||||
/** Currently selected file path. */
|
||||
selectedFile?: string | null;
|
||||
/** Expanded directory paths. */
|
||||
expandedPaths?: FileTreePathCollection;
|
||||
/** Checked file paths. */
|
||||
checkedPaths?: FileTreePathCollection;
|
||||
/** Called when a directory row is toggled. */
|
||||
onToggleDir?: (path: string) => void;
|
||||
/** Called when a file row is selected. */
|
||||
onSelectFile?: (path: string) => void;
|
||||
/** Called when a checkbox is toggled. */
|
||||
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
||||
/** Badge metadata keyed by path. */
|
||||
fileBadges?: Record<string, FileTreeBadge | undefined>;
|
||||
/** Row tone metadata keyed by path. */
|
||||
fileTones?: Record<string, FileTreeTone | undefined>;
|
||||
/** Whether to render checkboxes. Defaults to false for plugin UIs. */
|
||||
showCheckboxes?: boolean;
|
||||
/** Allow long file and directory names to wrap. */
|
||||
wrapLabels?: boolean;
|
||||
/** Render a loading skeleton instead of nodes. */
|
||||
loading?: boolean;
|
||||
/** Render a structured error state instead of nodes. */
|
||||
error?: FileTreeErrorState | null;
|
||||
/** Empty state content. */
|
||||
empty?: FileTreeEmptyState;
|
||||
/** Accessible label for the tree. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface IssuesListFilters {
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
parentId?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
labelId?: string;
|
||||
workspaceId?: string;
|
||||
executionWorkspaceId?: string;
|
||||
originKind?: string;
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
descendantOf?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuesListProps {
|
||||
companyId: string | null;
|
||||
projectId?: string | null;
|
||||
filters?: IssuesListFilters;
|
||||
viewStateKey?: string;
|
||||
initialSearch?: string;
|
||||
createIssueLabel?: string;
|
||||
searchWithinLoadedIssues?: boolean;
|
||||
}
|
||||
|
||||
export interface AssigneePickerSelection {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
export interface AssigneePickerProps {
|
||||
/** Company whose agents and users should be listed. Defaults to host context. */
|
||||
companyId?: string | null;
|
||||
/** Controlled value. Use `agent:<id>`, `user:<id>`, or an empty string. */
|
||||
value: string;
|
||||
/** Called with the encoded value plus parsed assignee IDs. */
|
||||
onChange: (value: string, selection: AssigneePickerSelection) => void;
|
||||
/** Button placeholder when no assignee is selected. */
|
||||
placeholder?: string;
|
||||
/** Label for the empty option. */
|
||||
noneLabel?: string;
|
||||
/** Search input placeholder. */
|
||||
searchPlaceholder?: string;
|
||||
/** Empty search result message. */
|
||||
emptyMessage?: string;
|
||||
/** Include active board users alongside agents. Defaults to true. */
|
||||
includeUsers?: boolean;
|
||||
/** Include terminated agents. Defaults to false. */
|
||||
includeTerminatedAgents?: boolean;
|
||||
/** CSS class forwarded to the trigger button. */
|
||||
className?: string;
|
||||
/** Called after the user confirms a selection with Enter, Tab, or click. */
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export interface ProjectPickerProps {
|
||||
/** Company whose projects should be listed. Defaults to host context. */
|
||||
companyId?: string | null;
|
||||
/** Controlled project id, or an empty string for no project. */
|
||||
value: string;
|
||||
/** Called with the selected project id. Empty string means no project. */
|
||||
onChange: (projectId: string) => void;
|
||||
/** Button placeholder when no project is selected. */
|
||||
placeholder?: string;
|
||||
/** Label for the empty option. */
|
||||
noneLabel?: string;
|
||||
/** Search input placeholder. */
|
||||
searchPlaceholder?: string;
|
||||
/** Empty search result message. */
|
||||
emptyMessage?: string;
|
||||
/** Include archived projects. Defaults to false. */
|
||||
includeArchived?: boolean;
|
||||
/** CSS class forwarded to the trigger button. */
|
||||
className?: string;
|
||||
/** Called after the user confirms a selection with Enter, Tab, or click. */
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export interface ManagedRoutinesListAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedRoutinesListProject {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedRoutineMissingRef {
|
||||
resourceKind: string;
|
||||
resourceKey: string;
|
||||
}
|
||||
|
||||
export interface ManagedRoutinesListItem {
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
routineId?: string | null;
|
||||
href?: string | null;
|
||||
resourceKey?: string | null;
|
||||
projectId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
cronExpression?: string | null;
|
||||
lastRunAt?: Date | string | null;
|
||||
lastRunStatus?: string | null;
|
||||
managedByPluginDisplayName?: string | null;
|
||||
missingRefs?: ManagedRoutineMissingRef[];
|
||||
}
|
||||
|
||||
export interface ManagedRoutinesListProps {
|
||||
routines: ManagedRoutinesListItem[];
|
||||
agents?: ManagedRoutinesListAgent[];
|
||||
projects?: ManagedRoutinesListProject[];
|
||||
pluginDisplayName?: string | null;
|
||||
emptyMessage?: string;
|
||||
runningRoutineKey?: string | null;
|
||||
statusMutationRoutineKey?: string | null;
|
||||
reconcilingRoutineKey?: string | null;
|
||||
resettingRoutineKey?: string | null;
|
||||
onRunNow?: (routine: ManagedRoutinesListItem) => void;
|
||||
onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void;
|
||||
onReconcile?: (routine: ManagedRoutinesListItem) => void;
|
||||
onReset?: (routine: ManagedRoutinesListItem) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component declarations (provided by host at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -266,6 +501,13 @@ export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("Times
|
||||
*/
|
||||
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
|
||||
|
||||
/**
|
||||
* Renders Paperclip's shared Markdown editor.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const MarkdownEditor = createSdkUiComponent<MarkdownEditorProps>("MarkdownEditor");
|
||||
|
||||
/**
|
||||
* Renders a definition-list of label/value pairs.
|
||||
*
|
||||
@@ -308,3 +550,40 @@ export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
|
||||
|
||||
/**
|
||||
* Renders the host file tree component with a stable plugin-safe prop surface.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui";
|
||||
*
|
||||
* const nodes: FileTreeNode[] = [
|
||||
* { name: "README.md", path: "README.md", kind: "file", children: [] },
|
||||
* ];
|
||||
*
|
||||
* <FileTree nodes={nodes} onSelectFile={(path) => console.log(path)} />;
|
||||
* ```
|
||||
*/
|
||||
export const FileTree = createSdkUiComponent<FileTreeProps>("FileTree");
|
||||
|
||||
/**
|
||||
* Renders Paperclip's native issue list component for company-scoped plugin
|
||||
* pages that need a standard board issue view.
|
||||
*/
|
||||
export const IssuesList = createSdkUiComponent<IssuesListProps>("IssuesList");
|
||||
|
||||
/**
|
||||
* Renders the same host assignee picker used by the new issue pane.
|
||||
*/
|
||||
export const AssigneePicker = createSdkUiComponent<AssigneePickerProps>("AssigneePicker");
|
||||
|
||||
/**
|
||||
* Renders the same host project picker used by the new issue pane.
|
||||
*/
|
||||
export const ProjectPicker = createSdkUiComponent<ProjectPickerProps>("ProjectPicker");
|
||||
|
||||
/**
|
||||
* Renders Paperclip's native managed routines list for plugin settings pages.
|
||||
*/
|
||||
export const ManagedRoutinesList = createSdkUiComponent<ManagedRoutinesListProps>("ManagedRoutinesList");
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
HostLocation,
|
||||
HostNavigation,
|
||||
PluginHostContext,
|
||||
PluginStreamResult,
|
||||
PluginToastFn,
|
||||
@@ -115,6 +117,57 @@ export function useHostContext(): PluginHostContext {
|
||||
return impl();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHostNavigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Navigate within the Paperclip host without forcing a full document reload.
|
||||
*
|
||||
* Use `linkProps()` for links so browser-native behavior still works:
|
||||
* modifier-click, middle-click, copy-link, and open-in-new-tab all use the
|
||||
* returned real `href`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function WikiSidebarLink() {
|
||||
* const hostNavigation = useHostNavigation();
|
||||
* return <a {...hostNavigation.linkProps("/wiki")}>Wiki</a>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHostNavigation(): HostNavigation {
|
||||
const impl = getSdkUiRuntimeValue<() => HostNavigation>("useHostNavigation");
|
||||
return impl();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHostLocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Observe the current host router location.
|
||||
*
|
||||
* Returns a snapshot of the active `pathname`, `search`, and `hash`. The
|
||||
* component re-renders when any of these change (e.g. after the host router
|
||||
* pushes a new entry, or after the browser back/forward gestures). Use this
|
||||
* for URL-driven plugin UI such as a takeover sidebar with section-aware
|
||||
* active state.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function WikiSection() {
|
||||
* const { pathname } = useHostLocation();
|
||||
* const section = pathname.split("/").filter(Boolean).at(-1) ?? "wiki";
|
||||
* return <div>Active section: {section}</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHostLocation(): HostLocation {
|
||||
const impl = getSdkUiRuntimeValue<() => HostLocation>("useHostLocation");
|
||||
return impl();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginStream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -43,20 +43,89 @@
|
||||
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
|
||||
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
|
||||
* - `useHostContext()` — read the current active company, project, entity, and user IDs
|
||||
* - `useHostNavigation()` — navigate Paperclip-internal links through the host router
|
||||
* - `useHostLocation()` — observe the current host pathname/search/hash for URL-driven UI
|
||||
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
|
||||
*/
|
||||
export {
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
useHostNavigation,
|
||||
useHostLocation,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./hooks.js";
|
||||
|
||||
export {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
DataTable,
|
||||
TimeseriesChart,
|
||||
MarkdownBlock,
|
||||
MarkdownEditor,
|
||||
KeyValueList,
|
||||
ActionBar,
|
||||
LogView,
|
||||
JsonTree,
|
||||
Spinner,
|
||||
ErrorBoundary,
|
||||
FileTree,
|
||||
IssuesList,
|
||||
AssigneePicker,
|
||||
ProjectPicker,
|
||||
ManagedRoutinesList,
|
||||
} from "./components.js";
|
||||
|
||||
export type {
|
||||
MetricTrend,
|
||||
MetricCardProps,
|
||||
StatusBadgeVariant,
|
||||
StatusBadgeProps,
|
||||
DataTableColumn,
|
||||
DataTableProps,
|
||||
TimeseriesDataPoint,
|
||||
TimeseriesChartProps,
|
||||
MarkdownBlockProps,
|
||||
MarkdownEditorProps,
|
||||
KeyValuePair,
|
||||
KeyValueListProps,
|
||||
ActionBarItem,
|
||||
ActionBarProps,
|
||||
LogViewEntry,
|
||||
LogViewProps,
|
||||
JsonTreeProps,
|
||||
SpinnerProps,
|
||||
ErrorBoundaryProps,
|
||||
FileTreeNode,
|
||||
FileTreeBadgeVariant,
|
||||
FileTreeBadge,
|
||||
FileTreeTone,
|
||||
FileTreeEmptyState,
|
||||
FileTreeErrorState,
|
||||
FileTreePathCollection,
|
||||
FileTreeProps,
|
||||
IssuesListFilters,
|
||||
IssuesListProps,
|
||||
AssigneePickerSelection,
|
||||
AssigneePickerProps,
|
||||
ProjectPickerProps,
|
||||
ManagedRoutineMissingRef,
|
||||
ManagedRoutinesListAgent,
|
||||
ManagedRoutinesListItem,
|
||||
ManagedRoutinesListProject,
|
||||
ManagedRoutinesListProps,
|
||||
} from "./components.js";
|
||||
|
||||
// Bridge error and host context types
|
||||
export type {
|
||||
PluginBridgeError,
|
||||
PluginBridgeErrorCode,
|
||||
HostNavigation,
|
||||
HostNavigationOptions,
|
||||
HostNavigationLinkOptions,
|
||||
HostNavigationLinkProps,
|
||||
HostLocation,
|
||||
PluginHostContext,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
@@ -80,6 +149,7 @@ export type {
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
PluginRouteSidebarProps,
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*/
|
||||
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
@@ -131,6 +135,83 @@ export interface PluginRenderEnvironmentContext
|
||||
closeLifecycle?: PluginRenderCloseLifecycle | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for host-managed Paperclip navigation from plugin UI.
|
||||
*/
|
||||
export interface HostNavigationOptions {
|
||||
/** Replace the current history entry instead of pushing a new one. */
|
||||
replace?: boolean;
|
||||
/** Optional state forwarded to the host router. */
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `useHostNavigation().linkProps()`.
|
||||
*/
|
||||
export interface HostNavigationLinkOptions extends HostNavigationOptions {
|
||||
/** Standard anchor target. Non-`_self` targets are not intercepted. */
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
/** Standard anchor rel attribute. */
|
||||
rel?: AnchorHTMLAttributes<HTMLAnchorElement>["rel"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor props returned by `useHostNavigation().linkProps()`.
|
||||
*
|
||||
* The `href` is always real so browser affordances such as copy-link,
|
||||
* modifier-click, middle-click, and open-in-new-tab continue to work.
|
||||
*/
|
||||
export interface HostNavigationLinkProps
|
||||
extends Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "target" | "rel"> {
|
||||
onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the host router location, exposed to plugin UI through
|
||||
* `useHostLocation()`. Mirrors the relevant subset of `Location` from
|
||||
* `react-router-dom` so plugins can react to URL changes without importing
|
||||
* router internals.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export interface HostLocation {
|
||||
/** Current pathname, e.g. `/PAP/wiki`. */
|
||||
pathname: string;
|
||||
/** Current search string, e.g. `?tab=config` (includes the leading `?`). */
|
||||
search: string;
|
||||
/** Current hash, e.g. `#document-plan` (includes the leading `#`). */
|
||||
hash: string;
|
||||
/** Optional state forwarded by the host router for same-tab SPA navigation. */
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-managed navigation helpers for plugin UI.
|
||||
*/
|
||||
export interface HostNavigation {
|
||||
/**
|
||||
* Resolve a Paperclip-internal path using the active company prefix.
|
||||
*
|
||||
* For example, in company `PAP`, `resolveHref("/wiki")` returns
|
||||
* `"/PAP/wiki"`, while `resolveHref("/PAP/wiki")` stays unchanged.
|
||||
*/
|
||||
resolveHref(to: string): string;
|
||||
/** Navigate through the host router without reloading the document. */
|
||||
navigate(to: string, options?: HostNavigationOptions): void;
|
||||
/**
|
||||
* Build anchor props for host-managed links.
|
||||
*
|
||||
* Plain left-clicks are routed through the host SPA router. Browser-native
|
||||
* link gestures are left alone because the returned props include a real
|
||||
* `href`.
|
||||
*/
|
||||
linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot component prop interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -188,6 +269,19 @@ export interface PluginSidebarProps {
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin route sidebar component.
|
||||
*
|
||||
* A route sidebar replaces the normal company sidebar while the user is on a
|
||||
* matching plugin page route declared with the same `routePath`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.5 — Sidebar Entries
|
||||
*/
|
||||
export interface PluginRouteSidebarProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin project sidebar item component.
|
||||
*
|
||||
|
||||
@@ -387,6 +387,51 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
},
|
||||
|
||||
localFolders: {
|
||||
declarations() {
|
||||
if (!manifest) throw new Error("Plugin context accessed before initialization");
|
||||
return manifest.localFolders ?? [];
|
||||
},
|
||||
|
||||
async configure(input) {
|
||||
return callHost("localFolders.configure", {
|
||||
companyId: input.companyId,
|
||||
folderKey: input.folderKey,
|
||||
path: input.path,
|
||||
access: input.access,
|
||||
requiredDirectories: input.requiredDirectories,
|
||||
requiredFiles: input.requiredFiles,
|
||||
});
|
||||
},
|
||||
|
||||
async status(companyId: string, folderKey: string) {
|
||||
return callHost("localFolders.status", { companyId, folderKey });
|
||||
},
|
||||
|
||||
async list(companyId: string, folderKey: string, options = {}) {
|
||||
return callHost("localFolders.list", {
|
||||
companyId,
|
||||
folderKey,
|
||||
relativePath: options.relativePath,
|
||||
recursive: options.recursive,
|
||||
maxEntries: options.maxEntries,
|
||||
});
|
||||
},
|
||||
|
||||
async readText(companyId: string, folderKey: string, relativePath: string) {
|
||||
return callHost("localFolders.readText", { companyId, folderKey, relativePath });
|
||||
},
|
||||
|
||||
async writeTextAtomic(companyId: string, folderKey: string, relativePath: string, contents: string) {
|
||||
return callHost("localFolders.writeTextAtomic", {
|
||||
companyId,
|
||||
folderKey,
|
||||
relativePath,
|
||||
contents,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
on(
|
||||
name: string,
|
||||
@@ -580,6 +625,50 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
async getWorkspaceForIssue(issueId: string, companyId: string) {
|
||||
return callHost("projects.getWorkspaceForIssue", { issueId, companyId });
|
||||
},
|
||||
|
||||
managed: {
|
||||
async get(projectKey: string, companyId: string) {
|
||||
return callHost("projects.managed.get", { projectKey, companyId });
|
||||
},
|
||||
async reconcile(projectKey: string, companyId: string) {
|
||||
return callHost("projects.managed.reconcile", { projectKey, companyId });
|
||||
},
|
||||
async reset(projectKey: string, companyId: string) {
|
||||
return callHost("projects.managed.reset", { projectKey, companyId });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey: string, companyId: string) {
|
||||
return callHost("routines.managed.get", { routineKey, companyId });
|
||||
},
|
||||
async reconcile(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||
) {
|
||||
return callHost("routines.managed.reconcile", { routineKey, companyId, ...overrides });
|
||||
},
|
||||
async reset(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||
) {
|
||||
return callHost("routines.managed.reset", { routineKey, companyId, ...overrides });
|
||||
},
|
||||
async update(routineKey: string, companyId: string, patch: { status?: string }) {
|
||||
return callHost("routines.managed.update", { routineKey, companyId, ...patch });
|
||||
},
|
||||
async run(
|
||||
routineKey: string,
|
||||
companyId: string,
|
||||
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||
) {
|
||||
return callHost("routines.managed.run", { routineKey, companyId, ...overrides });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
companies: {
|
||||
@@ -602,8 +691,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
projectId: input.projectId,
|
||||
assigneeAgentId: input.assigneeAgentId,
|
||||
originKind: input.originKind,
|
||||
originKindPrefix: input.originKindPrefix,
|
||||
originId: input.originId,
|
||||
status: input.status,
|
||||
includePluginOperations: input.includePluginOperations,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
});
|
||||
@@ -628,6 +719,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
assigneeUserId: input.assigneeUserId,
|
||||
requestDepth: input.requestDepth,
|
||||
billingCode: input.billingCode,
|
||||
surfaceVisibility: input.surfaceVisibility,
|
||||
originKind: input.originKind,
|
||||
originId: input.originId,
|
||||
originRunId: input.originRunId,
|
||||
@@ -863,6 +955,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason });
|
||||
},
|
||||
|
||||
managed: {
|
||||
async get(agentKey: string, companyId: string) {
|
||||
return callHost("agents.managed.get", { agentKey, companyId });
|
||||
},
|
||||
|
||||
async reconcile(agentKey: string, companyId: string) {
|
||||
return callHost("agents.managed.reconcile", { agentKey, companyId });
|
||||
},
|
||||
|
||||
async reset(agentKey: string, companyId: string) {
|
||||
return callHost("agents.managed.reset", { agentKey, companyId });
|
||||
},
|
||||
},
|
||||
|
||||
sessions: {
|
||||
async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) {
|
||||
return callHost("agents.sessions.create", {
|
||||
|
||||
@@ -190,6 +190,16 @@ export const ISSUE_ORIGIN_KINDS = [
|
||||
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||
export type PluginIssueOriginKind = `plugin:${string}`;
|
||||
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
|
||||
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
|
||||
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
|
||||
|
||||
export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind {
|
||||
return `plugin:${pluginKey}:operation`;
|
||||
}
|
||||
|
||||
export function isPluginOperationIssueOriginKind(originKind: string | null | undefined): boolean {
|
||||
return typeof originKind === "string" && /^plugin:[^:]+:operation(?::|$)/.test(originKind);
|
||||
}
|
||||
|
||||
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||
@@ -634,9 +644,12 @@ export const PLUGIN_CAPABILITIES = [
|
||||
"issue.comments.create",
|
||||
"issue.interactions.create",
|
||||
"issue.documents.write",
|
||||
"projects.managed",
|
||||
"routines.managed",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agents.managed",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
@@ -658,6 +671,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
"environment.drivers.register",
|
||||
"local.folders",
|
||||
// Agent Tools
|
||||
"agent.tools.register",
|
||||
// UI
|
||||
@@ -728,6 +742,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
||||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"routeSidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"globalToolbarButton",
|
||||
|
||||
@@ -25,6 +25,9 @@ export {
|
||||
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
ISSUE_ORIGIN_KINDS,
|
||||
ISSUE_SURFACE_VISIBILITIES,
|
||||
pluginOperationIssueOriginKind,
|
||||
isPluginOperationIssueOriginKind,
|
||||
ISSUE_RELATION_TYPES,
|
||||
ISSUE_TREE_CONTROL_MODES,
|
||||
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
|
||||
@@ -133,6 +136,7 @@ export {
|
||||
type BuiltInIssueOriginKind,
|
||||
type PluginIssueOriginKind,
|
||||
type IssueOriginKind,
|
||||
type IssueSurfaceVisibility,
|
||||
type IssueRelationType,
|
||||
type IssueTreeControlMode,
|
||||
type IssueTreeHoldReleasePolicyStrategy,
|
||||
@@ -303,6 +307,7 @@ export type {
|
||||
ProjectCodebase,
|
||||
ProjectCodebaseOrigin,
|
||||
ProjectGoalRef,
|
||||
ProjectManagedByPlugin,
|
||||
ProjectWorkspace,
|
||||
CompanySearchHighlight,
|
||||
CompanySearchIssueSummary,
|
||||
@@ -500,8 +505,14 @@ export type {
|
||||
CompanySecret,
|
||||
SecretProviderDescriptor,
|
||||
Routine,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineRevisionSnapshotRoutineV1,
|
||||
RoutineRevisionSnapshotTriggerV1,
|
||||
RoutineRevisionSnapshotV1,
|
||||
RoutineRevisionSnapshot,
|
||||
RoutineRevision,
|
||||
RoutineTrigger,
|
||||
RoutineRun,
|
||||
RoutineTriggerSecretMaterial,
|
||||
@@ -514,6 +525,15 @@ export type {
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedProjectDeclaration,
|
||||
PluginManagedRoutineDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineResolution,
|
||||
PluginManagedResourceKind,
|
||||
PluginManagedResourceRef,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
@@ -530,6 +550,7 @@ export type {
|
||||
PluginMigrationRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginCompanySettings,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
@@ -780,6 +801,10 @@ export {
|
||||
routineVariableSchema,
|
||||
runRoutineSchema,
|
||||
rotateRoutineTriggerSecretSchema,
|
||||
routineRevisionSnapshotRoutineV1Schema,
|
||||
routineRevisionSnapshotTriggerV1Schema,
|
||||
routineRevisionSnapshotV1Schema,
|
||||
routineRevisionSnapshotSchema,
|
||||
type CreateSecret,
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
describe("issue references", () => {
|
||||
it("normalizes identifiers to uppercase", () => {
|
||||
expect(normalizeIssueIdentifier("pap-123")).toBe("PAP-123");
|
||||
expect(normalizeIssueIdentifier("pc1a2-7")).toBe("PC1A2-7");
|
||||
expect(normalizeIssueIdentifier("not-an-issue")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -27,14 +28,14 @@ describe("issue references", () => {
|
||||
});
|
||||
|
||||
it("finds identifiers and issue paths in plain text", () => {
|
||||
expect(findIssueReferenceMatches("See PAP-1, /issues/PAP-2, and https://x.test/PAP/issues/pap-3.")).toEqual([
|
||||
expect(findIssueReferenceMatches("See PAP-1, /issues/PC1A2-2, and https://x.test/PAP/issues/pc1a2-3.")).toEqual([
|
||||
{ index: 4, length: 5, identifier: "PAP-1", matchedText: "PAP-1" },
|
||||
{ index: 11, length: 13, identifier: "PAP-2", matchedText: "/issues/PAP-2" },
|
||||
{ index: 11, length: 15, identifier: "PC1A2-2", matchedText: "/issues/PC1A2-2" },
|
||||
{
|
||||
index: 30,
|
||||
length: 31,
|
||||
identifier: "PAP-3",
|
||||
matchedText: "https://x.test/PAP/issues/pap-3",
|
||||
index: 32,
|
||||
length: 33,
|
||||
identifier: "PC1A2-3",
|
||||
matchedText: "https://x.test/PAP/issues/pc1a2-3",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z]+-\d+$/;
|
||||
export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]*-\d+$/;
|
||||
|
||||
export interface IssueReferenceMatch {
|
||||
index: number;
|
||||
@@ -7,7 +7,7 @@ export interface IssueReferenceMatch {
|
||||
matchedText: string;
|
||||
}
|
||||
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z]+-\d+/gi;
|
||||
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z][A-Z0-9]*-\d+/gi;
|
||||
|
||||
function preserveNewlinesAsWhitespace(value: string) {
|
||||
return value.replace(/[^\n]/g, " ");
|
||||
|
||||
@@ -89,7 +89,7 @@ export type {
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "./agent.js";
|
||||
export type { AssetImage } from "./asset.js";
|
||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
CompanySearchHighlight,
|
||||
CompanySearchIssueSummary,
|
||||
@@ -231,8 +231,14 @@ export type {
|
||||
} from "./secrets.js";
|
||||
export type {
|
||||
Routine,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineRevisionSnapshotRoutineV1,
|
||||
RoutineRevisionSnapshotTriggerV1,
|
||||
RoutineRevisionSnapshotV1,
|
||||
RoutineRevisionSnapshot,
|
||||
RoutineRevision,
|
||||
RoutineTrigger,
|
||||
RoutineRun,
|
||||
RoutineTriggerSecretMaterial,
|
||||
@@ -325,6 +331,15 @@ export type {
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedProjectDeclaration,
|
||||
PluginManagedRoutineDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineResolution,
|
||||
PluginManagedResourceKind,
|
||||
PluginManagedResourceRef,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
@@ -341,6 +356,7 @@ export type {
|
||||
PluginMigrationRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginCompanySettings,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
|
||||
@@ -16,7 +16,19 @@ import type {
|
||||
PluginDatabaseMigrationStatus,
|
||||
PluginDatabaseNamespaceMode,
|
||||
PluginDatabaseNamespaceStatus,
|
||||
AgentAdapterType,
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
IssuePriority,
|
||||
ProjectStatus,
|
||||
RoutineCatchUpPolicy,
|
||||
RoutineConcurrencyPolicy,
|
||||
RoutineStatus,
|
||||
IssueSurfaceVisibility,
|
||||
} from "../constants.js";
|
||||
import type { Agent } from "./agent.js";
|
||||
import type { Project } from "./project.js";
|
||||
import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – plugins declare config schemas as JSON Schema
|
||||
@@ -113,6 +125,162 @@ export interface PluginEnvironmentDriverDeclaration {
|
||||
configSchema: JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a normal Paperclip agent that a plugin can provision and later
|
||||
* resolve by stable key within each company.
|
||||
*/
|
||||
export interface PluginManagedAgentDeclaration {
|
||||
/** Stable identifier for this managed agent, unique within the plugin. */
|
||||
agentKey: string;
|
||||
/** Suggested visible agent name. */
|
||||
displayName: string;
|
||||
/** Optional suggested role. Defaults to `general`. */
|
||||
role?: AgentRole | string;
|
||||
/** Optional suggested title shown in agent surfaces. */
|
||||
title?: string | null;
|
||||
/** Optional icon for agent list/detail surfaces. */
|
||||
icon?: string | null;
|
||||
/** Suggested capability summary for the agent. */
|
||||
capabilities?: string | null;
|
||||
/** Suggested adapter type. Defaults to `process`. */
|
||||
adapterType?: AgentAdapterType | string;
|
||||
/**
|
||||
* Optional ordered list of compatible adapter types. When present, the host
|
||||
* prefers the most-used compatible adapter already configured in the company,
|
||||
* falling back to `adapterType`.
|
||||
*/
|
||||
adapterPreference?: Array<AgentAdapterType | string>;
|
||||
/** Suggested adapter configuration. */
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
/** Suggested Paperclip runtime configuration. */
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
/** Suggested permissions object. Normalized by the host on create/reset. */
|
||||
permissions?: Record<string, unknown>;
|
||||
/** Suggested starting status when no board approval is required. */
|
||||
status?: Extract<AgentStatus, "idle" | "paused">;
|
||||
/** Suggested monthly budget in cents. */
|
||||
budgetMonthlyCents?: number;
|
||||
/** Optional managed instructions content or pointer metadata for plugin UI. */
|
||||
instructions?: {
|
||||
entryFile?: string;
|
||||
content?: string;
|
||||
assetPath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a company-scoped local folder a trusted plugin wants the operator
|
||||
* to configure. The host treats this as a generic filesystem root: plugin
|
||||
* code may request required relative folders/files, then use SDK helpers for
|
||||
* path-safe reads and atomic writes under that root.
|
||||
*/
|
||||
export interface PluginLocalFolderDeclaration {
|
||||
/** Stable identifier for this folder, unique within the plugin. */
|
||||
folderKey: string;
|
||||
/** Human-readable name shown in plugin settings. */
|
||||
displayName: string;
|
||||
/** Optional operator-facing description. */
|
||||
description?: string;
|
||||
/** Access level requested by the plugin. Defaults to `readWrite`. */
|
||||
access?: "read" | "readWrite";
|
||||
/** Relative directories expected to exist under the configured root. */
|
||||
requiredDirectories?: string[];
|
||||
/** Relative files expected to exist under the configured root. */
|
||||
requiredFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a normal Paperclip project that a plugin can provision and later
|
||||
* resolve by stable key within each company.
|
||||
*/
|
||||
export interface PluginManagedProjectDeclaration {
|
||||
/** Stable identifier for this managed project, unique within the plugin. */
|
||||
projectKey: string;
|
||||
/** Suggested visible project name. */
|
||||
displayName: string;
|
||||
/** Suggested project description. */
|
||||
description?: string | null;
|
||||
/** Suggested starting status. Defaults to `in_progress`. */
|
||||
status?: ProjectStatus;
|
||||
/** Suggested project color. Defaults to the normal project palette. */
|
||||
color?: string | null;
|
||||
/** Optional plugin-specific defaults retained for reset/reconcile UI. */
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PluginManagedResourceKind = "agent" | "project" | "routine";
|
||||
|
||||
export interface PluginManagedResourceRef {
|
||||
pluginKey?: string;
|
||||
resourceKind: PluginManagedResourceKind;
|
||||
resourceKey: string;
|
||||
}
|
||||
|
||||
export interface PluginManagedRoutineDeclaration {
|
||||
/** Stable identifier for this managed routine, unique within the plugin. */
|
||||
routineKey: string;
|
||||
/** Suggested routine title template. */
|
||||
title: string;
|
||||
/** Suggested routine description template. */
|
||||
description?: string | null;
|
||||
/** Stable managed agent reference for the default assignee. */
|
||||
assigneeRef?: PluginManagedResourceRef | null;
|
||||
/** Stable managed project reference for routine-created issues. */
|
||||
projectRef?: PluginManagedResourceRef | null;
|
||||
/** Optional goal id to set on the routine in this company. */
|
||||
goalId?: string | null;
|
||||
/** Suggested starting status. Defaults to `paused` when no assignee is resolved, otherwise `active`. */
|
||||
status?: RoutineStatus;
|
||||
/** Suggested issue priority. Defaults to `medium`. */
|
||||
priority?: IssuePriority;
|
||||
/** Suggested concurrency behavior. Defaults to core routine default. */
|
||||
concurrencyPolicy?: RoutineConcurrencyPolicy;
|
||||
/** Suggested missed-trigger behavior. Defaults to core routine default. */
|
||||
catchUpPolicy?: RoutineCatchUpPolicy;
|
||||
/** Suggested routine variables. */
|
||||
variables?: RoutineVariable[];
|
||||
/** Suggested triggers created when the routine is first reconciled. */
|
||||
triggers?: Array<Pick<RoutineTrigger, "kind" | "label" | "enabled" | "cronExpression" | "timezone" | "signingMode" | "replayWindowSec">>;
|
||||
/** Defaults for issues created by this routine. */
|
||||
issueTemplate?: {
|
||||
surfaceVisibility?: IssueSurfaceVisibility;
|
||||
originId?: string | null;
|
||||
billingCode?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginManagedAgentResolution {
|
||||
pluginKey: string;
|
||||
resourceKind: "agent";
|
||||
resourceKey: string;
|
||||
companyId: string;
|
||||
agentId: string | null;
|
||||
agent: Agent | null;
|
||||
status: "missing" | "resolved" | "created" | "relinked" | "reset";
|
||||
approvalId?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginManagedProjectResolution {
|
||||
pluginKey: string;
|
||||
resourceKind: "project";
|
||||
resourceKey: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
project: Project | null;
|
||||
status: "missing" | "resolved" | "created" | "relinked" | "reset";
|
||||
}
|
||||
|
||||
export interface PluginManagedRoutineResolution {
|
||||
pluginKey: string;
|
||||
resourceKind: "routine";
|
||||
resourceKey: string;
|
||||
companyId: string;
|
||||
routineId: string | null;
|
||||
routine: Routine | null;
|
||||
status: "missing" | "missing_refs" | "resolved" | "created" | "relinked" | "reset";
|
||||
missingRefs?: PluginManagedResourceRef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a UI extension slot the plugin fills with a React component.
|
||||
*
|
||||
@@ -133,7 +301,7 @@ export interface PluginUiSlotDeclaration {
|
||||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/**
|
||||
* Optional company-scoped route segment for page slots.
|
||||
* Optional company-scoped route segment for page and routeSidebar slots.
|
||||
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
|
||||
*/
|
||||
routePath?: string;
|
||||
@@ -322,6 +490,14 @@ export interface PaperclipPluginManifestV1 {
|
||||
apiRoutes?: PluginApiRouteDeclaration[];
|
||||
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
|
||||
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
|
||||
/** Suggested company-scoped agents this plugin can provision and resolve by stable key. */
|
||||
agents?: PluginManagedAgentDeclaration[];
|
||||
/** Suggested company-scoped projects this plugin can provision and resolve by stable key. */
|
||||
projects?: PluginManagedProjectDeclaration[];
|
||||
/** Suggested company-scoped routines this plugin can provision and resolve by stable key. */
|
||||
routines?: PluginManagedRoutineDeclaration[];
|
||||
/** Trusted local folders this plugin can configure and access by stable key. */
|
||||
localFolders?: PluginLocalFolderDeclaration[];
|
||||
/**
|
||||
* Legacy top-level launcher declarations.
|
||||
* Prefer `ui.launchers` for new manifests.
|
||||
@@ -455,6 +631,22 @@ export interface PluginConfig {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Company-scoped plugin settings row. This is intentionally generic; plugin
|
||||
* features such as local folders live inside `settingsJson` under namespaced
|
||||
* keys instead of requiring feature-specific database columns.
|
||||
*/
|
||||
export interface PluginCompanySettings {
|
||||
id: string;
|
||||
companyId: string;
|
||||
pluginId: string;
|
||||
enabled: boolean;
|
||||
settingsJson: Record<string, unknown>;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query filter for `ctx.entities.list`.
|
||||
*/
|
||||
|
||||
@@ -52,6 +52,18 @@ export interface ProjectCodebase {
|
||||
origin: ProjectCodebaseOrigin;
|
||||
}
|
||||
|
||||
export interface ProjectManagedByPlugin {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
pluginDisplayName: string;
|
||||
resourceKind: "project";
|
||||
resourceKey: string;
|
||||
defaultsJson: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -73,6 +85,7 @@ export interface Project {
|
||||
codebase: ProjectCodebase;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
managedByPlugin?: ProjectManagedByPlugin | null;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { IssueOriginKind, RoutineVariableType } from "../constants.js";
|
||||
import type {
|
||||
IssueOriginKind,
|
||||
IssuePriority,
|
||||
RoutineCatchUpPolicy,
|
||||
RoutineConcurrencyPolicy,
|
||||
RoutineStatus,
|
||||
RoutineTriggerKind,
|
||||
RoutineTriggerSigningMode,
|
||||
RoutineVariableType,
|
||||
} from "../constants.js";
|
||||
|
||||
export interface RoutineProjectSummary {
|
||||
id: string;
|
||||
@@ -50,6 +59,8 @@ export interface Routine {
|
||||
concurrencyPolicy: string;
|
||||
catchUpPolicy: string;
|
||||
variables: RoutineVariable[];
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
@@ -58,6 +69,71 @@ export interface Routine {
|
||||
lastEnqueuedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
managedByPlugin?: RoutineManagedByPlugin | null;
|
||||
}
|
||||
|
||||
export interface RoutineManagedByPlugin {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
pluginDisplayName: string;
|
||||
resourceKind: "routine";
|
||||
resourceKey: string;
|
||||
defaultsJson: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RoutineRevisionSnapshotRoutineV1 {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
parentIssueId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
priority: IssuePriority;
|
||||
status: RoutineStatus;
|
||||
concurrencyPolicy: RoutineConcurrencyPolicy;
|
||||
catchUpPolicy: RoutineCatchUpPolicy;
|
||||
variables: RoutineVariable[];
|
||||
}
|
||||
|
||||
export interface RoutineRevisionSnapshotTriggerV1 {
|
||||
id: string;
|
||||
kind: RoutineTriggerKind;
|
||||
label: string | null;
|
||||
enabled: boolean;
|
||||
cronExpression: string | null;
|
||||
timezone: string | null;
|
||||
publicId: string | null;
|
||||
signingMode: RoutineTriggerSigningMode | null;
|
||||
replayWindowSec: number | null;
|
||||
}
|
||||
|
||||
export interface RoutineRevisionSnapshotV1 {
|
||||
version: 1;
|
||||
routine: RoutineRevisionSnapshotRoutineV1;
|
||||
triggers: RoutineRevisionSnapshotTriggerV1[];
|
||||
}
|
||||
|
||||
export type RoutineRevisionSnapshot = RoutineRevisionSnapshotV1;
|
||||
|
||||
export interface RoutineRevision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
routineId: string;
|
||||
revisionNumber: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
snapshot: RoutineRevisionSnapshot;
|
||||
changeSummary: string | null;
|
||||
restoredFromRevisionId: string | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdByRunId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface RoutineTrigger {
|
||||
|
||||
@@ -288,6 +288,10 @@ export {
|
||||
createRoutineTriggerSchema,
|
||||
updateRoutineTriggerSchema,
|
||||
routineVariableSchema,
|
||||
routineRevisionSnapshotRoutineV1Schema,
|
||||
routineRevisionSnapshotTriggerV1Schema,
|
||||
routineRevisionSnapshotV1Schema,
|
||||
routineRevisionSnapshotSchema,
|
||||
runRoutineSchema,
|
||||
rotateRoutineTriggerSecretSchema,
|
||||
type CreateRoutine,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLUGIN_CAPABILITIES } from "../constants.js";
|
||||
import { pluginManagedRoutineDeclarationSchema, pluginUiSlotDeclarationSchema } from "./plugin.js";
|
||||
|
||||
describe("plugin capability constants", () => {
|
||||
it("exposes each capability once", () => {
|
||||
expect(new Set(PLUGIN_CAPABILITIES).size).toBe(PLUGIN_CAPABILITIES.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin managed routine validators", () => {
|
||||
it("accepts core issue surface visibility values in routine templates", () => {
|
||||
const parsed = pluginManagedRoutineDeclarationSchema.parse({
|
||||
routineKey: "wiki.refresh",
|
||||
title: "Refresh Wiki",
|
||||
issueTemplate: { surfaceVisibility: "default" },
|
||||
});
|
||||
|
||||
expect(parsed.issueTemplate?.surfaceVisibility).toBe("default");
|
||||
});
|
||||
|
||||
it("rejects non-core issue surface visibility values in routine templates", () => {
|
||||
const parsed = pluginManagedRoutineDeclarationSchema.safeParse({
|
||||
routineKey: "wiki.refresh",
|
||||
title: "Refresh Wiki",
|
||||
issueTemplate: { surfaceVisibility: "normal" },
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin UI slot validators", () => {
|
||||
it("accepts route-scoped sidebar slots with a routePath", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.parse({
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
});
|
||||
|
||||
expect(parsed.routePath).toBe("wiki");
|
||||
});
|
||||
|
||||
it("requires route-scoped sidebar slots to declare a routePath", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.safeParse({
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues[0]?.message).toBe("routeSidebar slots require routePath");
|
||||
});
|
||||
|
||||
it("keeps reserved company route protection for route-scoped sidebars", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.safeParse({
|
||||
type: "routeSidebar",
|
||||
id: "settings-route-sidebar",
|
||||
displayName: "Settings Sidebar",
|
||||
exportName: "SettingsSidebar",
|
||||
routePath: "settings",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,15 @@ import {
|
||||
PLUGIN_API_ROUTE_AUTH_MODES,
|
||||
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
|
||||
PLUGIN_API_ROUTE_METHODS,
|
||||
ISSUE_PRIORITIES,
|
||||
ROUTINE_CATCH_UP_POLICIES,
|
||||
ROUTINE_CONCURRENCY_POLICIES,
|
||||
ROUTINE_STATUSES,
|
||||
ROUTINE_TRIGGER_KINDS,
|
||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
ISSUE_SURFACE_VISIBILITIES,
|
||||
} from "../constants.js";
|
||||
import { routineVariableSchema } from "./routine.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
||||
@@ -124,6 +132,106 @@ export type PluginEnvironmentDriverDeclarationInput = z.infer<
|
||||
|
||||
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
|
||||
|
||||
export const pluginManagedAgentDeclarationSchema = z.object({
|
||||
agentKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "agentKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(100),
|
||||
role: z.string().min(1).max(100).optional(),
|
||||
title: z.string().max(200).nullable().optional(),
|
||||
icon: z.string().max(100).nullable().optional(),
|
||||
capabilities: z.string().max(2000).nullable().optional(),
|
||||
adapterType: z.string().min(1).max(100).optional(),
|
||||
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
runtimeConfig: z.record(z.unknown()).optional(),
|
||||
permissions: z.record(z.unknown()).optional(),
|
||||
status: z.enum(["idle", "paused"]).optional(),
|
||||
budgetMonthlyCents: z.number().int().min(0).optional(),
|
||||
instructions: z.object({
|
||||
entryFile: z.string().min(1).max(200).optional(),
|
||||
content: z.string().max(200_000).optional(),
|
||||
assetPath: z.string().min(1).max(500).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedAgentDeclarationInput = z.infer<typeof pluginManagedAgentDeclarationSchema>;
|
||||
|
||||
export const pluginManagedProjectDeclarationSchema = z.object({
|
||||
projectKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "projectKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(),
|
||||
color: z.string().max(32).nullable().optional(),
|
||||
settings: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>;
|
||||
|
||||
const pluginManagedResourceRefSchema = z.object({
|
||||
pluginKey: z.string().min(1).max(100).optional(),
|
||||
resourceKind: z.enum(["agent", "project", "routine"]),
|
||||
resourceKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "resourceKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
});
|
||||
|
||||
export const pluginManagedRoutineDeclarationSchema = z.object({
|
||||
routineKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "routineKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().max(10_000).nullable().optional(),
|
||||
assigneeRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("agent") }).nullable().optional(),
|
||||
projectRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("project") }).nullable().optional(),
|
||||
goalId: z.string().uuid().nullable().optional(),
|
||||
status: z.enum(ROUTINE_STATUSES).optional(),
|
||||
priority: z.enum(ISSUE_PRIORITIES).optional(),
|
||||
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(),
|
||||
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(),
|
||||
variables: z.array(routineVariableSchema).optional(),
|
||||
triggers: z.array(z.object({
|
||||
kind: z.enum(ROUTINE_TRIGGER_KINDS),
|
||||
label: z.string().trim().max(120).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
cronExpression: z.string().trim().min(1).optional().nullable(),
|
||||
timezone: z.string().trim().min(1).optional().nullable(),
|
||||
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
|
||||
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
|
||||
})).max(20).optional(),
|
||||
issueTemplate: z.object({
|
||||
surfaceVisibility: z.enum(ISSUE_SURFACE_VISIBILITIES).optional(),
|
||||
originId: z.string().trim().max(255).nullable().optional(),
|
||||
billingCode: z.string().trim().max(200).nullable().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedRoutineDeclarationInput = z.infer<typeof pluginManagedRoutineDeclarationSchema>;
|
||||
|
||||
const pluginLocalFolderRelativePathSchema = z.string().min(1).max(500).refine(
|
||||
(value) =>
|
||||
!value.startsWith("/") &&
|
||||
!value.includes("..") &&
|
||||
!value.includes("\\") &&
|
||||
!value.split("/").some((segment) => segment === "" || segment === "."),
|
||||
{ message: "local folder paths must be relative paths without traversal, empty segments, or backslashes" },
|
||||
);
|
||||
|
||||
export const pluginLocalFolderDeclarationSchema = z.object({
|
||||
folderKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
access: z.enum(["read", "readWrite"]).optional(),
|
||||
requiredDirectories: z.array(pluginLocalFolderRelativePathSchema).optional(),
|
||||
requiredFiles: z.array(pluginLocalFolderRelativePathSchema).optional(),
|
||||
});
|
||||
|
||||
export type PluginLocalFolderDeclarationInput = z.infer<typeof pluginLocalFolderDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
|
||||
* fills with a React component. Includes `superRefine` checks for slot-specific
|
||||
@@ -178,10 +286,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && value.type !== "page") {
|
||||
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routePath is only supported for page slots",
|
||||
message: "routePath is only supported for page and routeSidebar slots",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (value.type === "routeSidebar" && !value.routePath) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routeSidebar slots require routePath",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
@@ -471,6 +586,10 @@ export const pluginManifestV1Schema = z.object({
|
||||
database: pluginDatabaseDeclarationSchema.optional(),
|
||||
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
|
||||
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(),
|
||||
agents: z.array(pluginManagedAgentDeclarationSchema).optional(),
|
||||
projects: z.array(pluginManagedProjectDeclarationSchema).optional(),
|
||||
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
|
||||
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
@@ -529,6 +648,46 @@ export const pluginManifestV1Schema = z.object({
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.agents && manifest.agents.length > 0) {
|
||||
if (!manifest.capabilities.includes("agents.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'agents.managed' is required when managed agents are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.projects && manifest.projects.length > 0) {
|
||||
if (!manifest.capabilities.includes("projects.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'projects.managed' is required when managed projects are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.routines && manifest.routines.length > 0) {
|
||||
if (!manifest.capabilities.includes("routines.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'routines.managed' is required when managed routines are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.localFolders && manifest.localFolders.length > 0) {
|
||||
if (!manifest.capabilities.includes("local.folders")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'local.folders' is required when local folders are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||
if (!manifest.capabilities.includes("jobs.schedule")) {
|
||||
@@ -664,6 +823,54 @@ export const pluginManifestV1Schema = z.object({
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.localFolders) {
|
||||
const folderKeys = manifest.localFolders.map((folder) => folder.folderKey);
|
||||
const duplicates = folderKeys.filter((key, i) => folderKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate local folder keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["localFolders"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.agents) {
|
||||
const agentKeys = manifest.agents.map((agent) => agent.agentKey);
|
||||
const duplicates = agentKeys.filter((key, i) => agentKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed agent keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["agents"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.projects) {
|
||||
const projectKeys = manifest.projects.map((project) => project.projectKey);
|
||||
const duplicates = projectKeys.filter((key, i) => projectKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed project keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["projects"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.routines) {
|
||||
const routineKeys = manifest.routines.map((routine) => routine.routineKey);
|
||||
const duplicates = routineKeys.filter((key, i) => routineKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed routine keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["routines"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
|
||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user