Merge origin/master into search page branch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-05 12:00:38 -05:00
commit a7c593ce95
226 files changed
+55254 -2082

No files matched your search

+1 -1
View File
@@ -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
+83 -8
View File
@@ -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
+12
View File
@@ -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
+52
View File
@@ -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.
+21
View File
@@ -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:
+184 -1
View File
@@ -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:
+17 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+338 -45
View File
@@ -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([
+1
View File
@@ -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);
+125 -2
View File
@@ -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,
+82 -49
View File
@@ -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[],
+23
View File
@@ -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.",
+2
View File
@@ -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");
});
});
+122 -13
View File
@@ -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`,
);
}
+23 -1
View File
@@ -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 diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+17 -3
View File
@@ -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
}
]
+2 -1
View File
@@ -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,
),
}),
);
+38 -1
View File
@@ -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);
}
}
},
});
+180 -8
View File
@@ -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>
);
+2 -1
View File
@@ -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) => {
+17
View File
@@ -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,
+112
View File
@@ -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": [
+560 -1
View File
@@ -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");
+156
View File
@@ -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;
+279
View File
@@ -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");
+53
View File
@@ -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
// ---------------------------------------------------------------------------
+70
View File
@@ -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,
+94
View File
@@ -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.
*
+106
View File
@@ -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", {
+15
View File
@@ -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
View File
@@ -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,
+7 -6
View File
@@ -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",
},
]);
});
+2 -2
View File
@@ -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, " ");
+17 -1
View File
@@ -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,
+193 -1
View File
@@ -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`.
*/
+13
View File
@@ -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;
+77 -1
View File
@@ -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 {
+4
View File
@@ -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);
});
});
+209 -2
View File
@@ -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