Add exe.dev sandbox provider plugin (#5688)
> _Stacked on top of #5685 → #5686 → #5687. Diff against master includes
commits from earlier PRs in the stack — review focuses on the two new
commits (`Add long-secret textarea variant to JsonSchemaForm
SecretField` + `Add exe.dev sandbox provider plugin`)._
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent runs in a sandbox environment, and operators choose the
provider — today E2B, Daytona, and (in this stack) Cloudflare
> - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful
for operators who want full Linux VMs (vs container/runtime-only
sandboxes)
> - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`,
`ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access
for adapters that need it
> - exe.dev VMs come up bare — `node` is not preinstalled, so the
Paperclip sandbox callback bridge (a Node script) needs Node 20
installed at VM init via `--setup-script`. The plugin defaults the setup
script to a Nodesource install
> - The auth field accepts long SSH private keys, which need a textarea
variant of the existing `SecretField` in `JsonSchemaForm` — added behind
a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected
> - The benefit is that operators get exe.dev as a fully working sandbox
provider out of the box, with no manual VM provisioning required
## What Changed
**Shared UI support (`Add long-secret textarea variant to JsonSchemaForm
SecretField`):**
- `ui/src/components/JsonSchemaForm.tsx` + new
`JsonSchemaForm.test.tsx`: when a secret-formatted field declares
`maxLength` larger than the existing single-line threshold, render a
monospace textarea instead of the masked input. Short secrets (API keys,
tokens) keep the existing masked-input + show/hide toggle behavior.
**The exe.dev plugin (`Add exe.dev sandbox provider plugin`):**
- `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest,
plugin runtime, README, and 19-test Vitest suite.
- Manifest fields: API token (with `secret-ref` + `/exec` permission
notes — needs `new`, `ls`, `rm`), API URL override, optional SSH
username, optional SSH private key (uses the new `JsonSchemaForm`
textarea variant via `maxLength: 4096`), optional SSH identity-file
path, optional setup script.
- Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs
come up bare and the Paperclip sandbox callback bridge is a Node script,
so without Node preinstalled the bridge can't start. Operators can
override by supplying their own setup script.
- `runLifecycleCommand` redacts env values from the executed command
before surfacing it in error messages, so secrets passed via
`--env=KEY=VALUE` don't leak into operator-visible failures.
- The plugin distinguishes exe.dev's SSH onboarding failures (`Please
complete registration by running: ssh exe.dev`) from general SSH
failures and surfaces a clear remediation message.
- `scripts/release-package-manifest.json`: register the new plugin for
CI publish alongside the existing daytona / e2b providers.
## Verification
- `pnpm typecheck`
- `pnpm exec vitest run --no-coverage
ui/src/components/JsonSchemaForm.test.tsx`
- `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19
passing
For an operator-side smoke test:
1. Get an exe.dev API token with `/exec` permission for `new`, `ls`,
`rm`.
2. Register the plugin in your Paperclip instance, configure an
environment with the token.
3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or
Claude job against it. The default Node 20 setup script should bring the
VM up automatically.
## Risks
- Adds a new sandbox provider plugin that follows the existing daytona /
e2b shape; behavior on existing providers is unchanged.
- The `JsonSchemaForm` textarea variant only engages for fields that opt
in via `maxLength` larger than the existing threshold. All existing
secret fields (which don't declare a `maxLength`) keep their current
rendering. Test coverage pins both paths.
- The redaction in `runLifecycleCommand` is a defense-in-depth measure;
the test suite exercises the redaction path. If the redaction misses a
future env-arg shape, the worst case is restored behavior (secrets in
error messages), which is what the existing daytona / e2b plugins also
do today.
- Default setup script downloads from `deb.nodesource.com` over HTTPS at
VM init. Operators on air-gapped networks or with a different package
strategy can override the setup script.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — UI change is a textarea variant of an existing secret
field; will attach screenshots before requesting merge
- [x] I have updated relevant documentation to reflect my changes
(plugin README, manifest descriptions)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 07:42:18 -07:00
import path from "node:path" ;
import { randomUUID } from "node:crypto" ;
import { chmod , mkdtemp , rm , writeFile } from "node:fs/promises" ;
import { tmpdir } from "node:os" ;
import { spawn } from "node:child_process" ;
import { definePlugin } from "@paperclipai/plugin-sdk" ;
import type {
PluginEnvironmentAcquireLeaseParams ,
PluginEnvironmentDestroyLeaseParams ,
PluginEnvironmentExecuteParams ,
PluginEnvironmentExecuteResult ,
PluginEnvironmentLease ,
PluginEnvironmentProbeParams ,
PluginEnvironmentProbeResult ,
PluginEnvironmentRealizeWorkspaceParams ,
PluginEnvironmentRealizeWorkspaceResult ,
PluginEnvironmentReleaseLeaseParams ,
PluginEnvironmentResumeLeaseParams ,
PluginEnvironmentValidateConfigParams ,
PluginEnvironmentValidationResult ,
} from "@paperclipai/plugin-sdk" ;
interface ExeDevDriverConfig {
apiKey : string | null ;
apiUrl : string ;
namePrefix : string ;
image : string | null ;
command : string | null ;
cpu : number | null ;
memory : string | null ;
disk : string | null ;
comment : string | null ;
env : Record < string , string > ;
integrations : string [ ] ;
tags : string [ ] ;
setupScript : string | null ;
prompt : string | null ;
timeoutMs : number ;
reuseLease : boolean ;
sshUser : string | null ;
sshPrivateKey : string | null ;
sshIdentityFile : string | null ;
sshPort : number ;
strictHostKeyChecking : string ;
}
interface ExeDevVmRecord {
name : string ;
sshDest : string ;
httpsUrl : string | null ;
status : string | null ;
region : string | null ;
regionDisplay : string | null ;
}
interface SshExecutionResult {
exitCode : number | null ;
signal : string | null ;
timedOut : boolean ;
stdout : string ;
stderr : string ;
}
const DEFAULT_API_URL = "https://exe.dev/exec" ;
const DEFAULT_TIMEOUT_MS = 300 _000 ;
const EXE_DEV_API_MAX_TIMEOUT_MS = 29 _000 ;
const SSH_SIGKILL_GRACE_MS = 250 ;
const MAX_VM_RECORD_DEPTH = 4 ;
const EXE_DEV_SSH_ONBOARDING_MARKER = "Please complete registration by running: ssh exe.dev" ;
const EXE_DEV_SSH_EMAIL_PROMPT = "Please enter your email address:" ;
exe.dev config UX: advanced-options disclosure, form-default fix, SSH key handling (PAPA-407) (#7025)
## Thinking Path
> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.
## What Changed
- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.
## Verification
CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.
Run locally:
```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.
# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```
Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):
1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).
## Risks
- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination
## Checklist
- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 18:19:37 -07:00
const EXE_DEV_SSH_INVALID_KEY_FORMAT = /Load key [^\n]*invalid format/i ;
const UUID_SECRET_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i ;
Add exe.dev sandbox provider plugin (#5688)
> _Stacked on top of #5685 → #5686 → #5687. Diff against master includes
commits from earlier PRs in the stack — review focuses on the two new
commits (`Add long-secret textarea variant to JsonSchemaForm
SecretField` + `Add exe.dev sandbox provider plugin`)._
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent runs in a sandbox environment, and operators choose the
provider — today E2B, Daytona, and (in this stack) Cloudflare
> - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful
for operators who want full Linux VMs (vs container/runtime-only
sandboxes)
> - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`,
`ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access
for adapters that need it
> - exe.dev VMs come up bare — `node` is not preinstalled, so the
Paperclip sandbox callback bridge (a Node script) needs Node 20
installed at VM init via `--setup-script`. The plugin defaults the setup
script to a Nodesource install
> - The auth field accepts long SSH private keys, which need a textarea
variant of the existing `SecretField` in `JsonSchemaForm` — added behind
a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected
> - The benefit is that operators get exe.dev as a fully working sandbox
provider out of the box, with no manual VM provisioning required
## What Changed
**Shared UI support (`Add long-secret textarea variant to JsonSchemaForm
SecretField`):**
- `ui/src/components/JsonSchemaForm.tsx` + new
`JsonSchemaForm.test.tsx`: when a secret-formatted field declares
`maxLength` larger than the existing single-line threshold, render a
monospace textarea instead of the masked input. Short secrets (API keys,
tokens) keep the existing masked-input + show/hide toggle behavior.
**The exe.dev plugin (`Add exe.dev sandbox provider plugin`):**
- `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest,
plugin runtime, README, and 19-test Vitest suite.
- Manifest fields: API token (with `secret-ref` + `/exec` permission
notes — needs `new`, `ls`, `rm`), API URL override, optional SSH
username, optional SSH private key (uses the new `JsonSchemaForm`
textarea variant via `maxLength: 4096`), optional SSH identity-file
path, optional setup script.
- Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs
come up bare and the Paperclip sandbox callback bridge is a Node script,
so without Node preinstalled the bridge can't start. Operators can
override by supplying their own setup script.
- `runLifecycleCommand` redacts env values from the executed command
before surfacing it in error messages, so secrets passed via
`--env=KEY=VALUE` don't leak into operator-visible failures.
- The plugin distinguishes exe.dev's SSH onboarding failures (`Please
complete registration by running: ssh exe.dev`) from general SSH
failures and surfaces a clear remediation message.
- `scripts/release-package-manifest.json`: register the new plugin for
CI publish alongside the existing daytona / e2b providers.
## Verification
- `pnpm typecheck`
- `pnpm exec vitest run --no-coverage
ui/src/components/JsonSchemaForm.test.tsx`
- `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19
passing
For an operator-side smoke test:
1. Get an exe.dev API token with `/exec` permission for `new`, `ls`,
`rm`.
2. Register the plugin in your Paperclip instance, configure an
environment with the token.
3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or
Claude job against it. The default Node 20 setup script should bring the
VM up automatically.
## Risks
- Adds a new sandbox provider plugin that follows the existing daytona /
e2b shape; behavior on existing providers is unchanged.
- The `JsonSchemaForm` textarea variant only engages for fields that opt
in via `maxLength` larger than the existing threshold. All existing
secret fields (which don't declare a `maxLength`) keep their current
rendering. Test coverage pins both paths.
- The redaction in `runLifecycleCommand` is a defense-in-depth measure;
the test suite exercises the redaction path. If the redaction misses a
future env-arg shape, the worst case is restored behavior (secrets in
error messages), which is what the existing daytona / e2b plugins also
do today.
- Default setup script downloads from `deb.nodesource.com` over HTTPS at
VM init. Operators on air-gapped networks or with a different package
strategy can override the setup script.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — UI change is a textarea variant of an existing secret
field; will attach screenshots before requesting merge
- [x] I have updated relevant documentation to reflect my changes
(plugin README, manifest descriptions)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 07:42:18 -07:00
// exe.dev's `--setup-script` runs at VM init as the unprivileged `exedev` user, which
// has passwordless sudo. The Paperclip sandbox callback bridge is a Node script, so
// every Paperclip workload on this provider needs node on PATH before the bridge can
// start. When the operator hasn't supplied their own setup script, install Node 20 via
// nodesource so the VM comes up ready for Paperclip out of the box.
const DEFAULT_SETUP_SCRIPT =
"command -v node >/dev/null 2>&1 || " +
"(curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && " +
"sudo apt-get install -y nodejs)" ;
class ExeDevApiError extends Error {
readonly status : number ;
readonly body : string ;
constructor ( message : string , status : number , body : string ) {
super ( message ) ;
this . name = "ExeDevApiError" ;
this . status = status ;
this . body = body ;
}
}
function parseOptionalString ( value : unknown ) : string | null {
if ( typeof value === "number" && Number . isFinite ( value ) ) return String ( value ) ;
return typeof value === "string" && value . trim ( ) . length > 0 ? value . trim ( ) : null ;
}
function parseOptionalInteger ( value : unknown ) : number | null {
if ( value == null || value === "" ) return null ;
const parsed = Number ( value ) ;
return Number . isFinite ( parsed ) ? Math . trunc ( parsed ) : null ;
}
function parseStringArray ( value : unknown ) : string [ ] {
if ( Array . isArray ( value ) ) {
return value
. map ( ( entry ) = > parseOptionalString ( entry ) )
. filter ( ( entry ) : entry is string = > entry != null ) ;
}
if ( typeof value === "string" ) {
return value
. split ( "," )
. map ( ( entry ) = > entry . trim ( ) )
. filter ( ( entry ) = > entry . length > 0 ) ;
}
return [ ] ;
}
function parseEnvMap ( value : unknown ) : Record < string , string > {
if ( ! value || typeof value !== "object" || Array . isArray ( value ) ) return { } ;
const env : Record < string , string > = { } ;
for ( const [ key , raw ] of Object . entries ( value ) ) {
const normalizedKey = key . trim ( ) ;
const normalizedValue = parseOptionalString ( raw ) ;
if ( normalizedKey . length > 0 && normalizedValue != null ) {
env [ normalizedKey ] = normalizedValue ;
}
}
return env ;
}
function isValidUrl ( value : string ) : boolean {
try {
new URL ( value ) ;
return true ;
} catch {
return false ;
}
}
exe.dev config UX: advanced-options disclosure, form-default fix, SSH key handling (PAPA-407) (#7025)
## Thinking Path
> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.
## What Changed
- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.
## Verification
CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.
Run locally:
```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.
# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```
Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):
1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).
## Risks
- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination
## Checklist
- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 18:19:37 -07:00
function isSecretRef ( value : string ) : boolean {
return UUID_SECRET_REF_RE . test ( value ) ;
}
// Catch the SSH-key paste failure modes we've seen in the wild (wrong file,
// PPK export, truncated paste) before the user pays the cost of provisioning a
// VM and getting a cryptic SSH error. Inline parse — no `ssh-keygen` dependency
// — so this also works on hosts where openssh-client isn't installed.
export function validateSshPrivateKey ( rawKey : string ) : string | null {
const trimmed = rawKey . trim ( ) ;
if ( ! trimmed ) return null ;
if ( /^PuTTY-User-Key-File-\d/m . test ( trimmed ) ) {
return "sshPrivateKey looks like a PuTTY .ppk file. Convert it to OpenSSH format (PuTTYgen → Conversions → Export OpenSSH key) and paste the resulting PEM." ;
}
if (
/^(?:ssh-(?:rsa|dss|ed25519)|ecdsa-sha2-[a-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-[a-z0-9-]+)@openssh\.com)\s+\S/ . test (
trimmed ,
)
) {
return "sshPrivateKey looks like a PUBLIC key. Paste the matching private key (the file without the .pub extension)." ;
}
const headerMatch = trimmed . match ( /^-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/m ) ;
if ( ! headerMatch ) {
return "sshPrivateKey must be a PEM-encoded private key starting with a line like '-----BEGIN OPENSSH PRIVATE KEY-----'." ;
}
const footerMatch = trimmed . match ( /^-----END ([A-Z0-9 ]*)PRIVATE KEY-----\s*$/m ) ;
if ( ! footerMatch ) {
return "sshPrivateKey is missing its '-----END … PRIVATE KEY-----' footer. Make sure you copied the whole file, including the final line." ;
}
const headerLabel = headerMatch [ 1 ] . trim ( ) ;
const footerLabel = footerMatch [ 1 ] . trim ( ) ;
if ( headerLabel !== footerLabel ) {
return ` sshPrivateKey header/footer mismatch (BEGIN ${ headerLabel || "(none)" } vs END ${ footerLabel || "(none)" } ). The file is likely truncated or two keys are concatenated. ` ;
}
const headerLineEnd = trimmed . indexOf ( "\n" , headerMatch . index ? ? 0 ) ;
const footerStart = trimmed . lastIndexOf ( footerMatch [ 0 ] ) ;
if ( headerLineEnd < 0 || footerStart <= headerLineEnd ) {
return "sshPrivateKey appears to be empty between its BEGIN and END markers." ;
}
const bodyLines = trimmed
. slice ( headerLineEnd + 1 , footerStart )
. split ( /\r?\n/ )
. map ( ( line ) = > line . trim ( ) )
. filter ( ( line ) = > line . length > 0 ) ;
if ( bodyLines . length === 0 ) {
return "sshPrivateKey appears to be empty between its BEGIN and END markers." ;
}
// PEM bodies are base64 lines, optionally preceded by `Header: value` lines
// on encrypted PKCS#1 keys (`Proc-Type:`, `DEK-Info:`).
const base64Line = /^[A-Za-z0-9+/=]+$/ ;
const pemHeaderLine = /^[A-Za-z][A-Za-z0-9-]*:\s.+$/ ;
for ( const line of bodyLines ) {
if ( ! base64Line . test ( line ) && ! pemHeaderLine . test ( line ) ) {
return "sshPrivateKey body contains non-base64 characters. The key may have been corrupted by line-wrapping or copy-paste." ;
}
}
return null ;
}
Add exe.dev sandbox provider plugin (#5688)
> _Stacked on top of #5685 → #5686 → #5687. Diff against master includes
commits from earlier PRs in the stack — review focuses on the two new
commits (`Add long-secret textarea variant to JsonSchemaForm
SecretField` + `Add exe.dev sandbox provider plugin`)._
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent runs in a sandbox environment, and operators choose the
provider — today E2B, Daytona, and (in this stack) Cloudflare
> - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful
for operators who want full Linux VMs (vs container/runtime-only
sandboxes)
> - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`,
`ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access
for adapters that need it
> - exe.dev VMs come up bare — `node` is not preinstalled, so the
Paperclip sandbox callback bridge (a Node script) needs Node 20
installed at VM init via `--setup-script`. The plugin defaults the setup
script to a Nodesource install
> - The auth field accepts long SSH private keys, which need a textarea
variant of the existing `SecretField` in `JsonSchemaForm` — added behind
a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected
> - The benefit is that operators get exe.dev as a fully working sandbox
provider out of the box, with no manual VM provisioning required
## What Changed
**Shared UI support (`Add long-secret textarea variant to JsonSchemaForm
SecretField`):**
- `ui/src/components/JsonSchemaForm.tsx` + new
`JsonSchemaForm.test.tsx`: when a secret-formatted field declares
`maxLength` larger than the existing single-line threshold, render a
monospace textarea instead of the masked input. Short secrets (API keys,
tokens) keep the existing masked-input + show/hide toggle behavior.
**The exe.dev plugin (`Add exe.dev sandbox provider plugin`):**
- `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest,
plugin runtime, README, and 19-test Vitest suite.
- Manifest fields: API token (with `secret-ref` + `/exec` permission
notes — needs `new`, `ls`, `rm`), API URL override, optional SSH
username, optional SSH private key (uses the new `JsonSchemaForm`
textarea variant via `maxLength: 4096`), optional SSH identity-file
path, optional setup script.
- Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs
come up bare and the Paperclip sandbox callback bridge is a Node script,
so without Node preinstalled the bridge can't start. Operators can
override by supplying their own setup script.
- `runLifecycleCommand` redacts env values from the executed command
before surfacing it in error messages, so secrets passed via
`--env=KEY=VALUE` don't leak into operator-visible failures.
- The plugin distinguishes exe.dev's SSH onboarding failures (`Please
complete registration by running: ssh exe.dev`) from general SSH
failures and surfaces a clear remediation message.
- `scripts/release-package-manifest.json`: register the new plugin for
CI publish alongside the existing daytona / e2b providers.
## Verification
- `pnpm typecheck`
- `pnpm exec vitest run --no-coverage
ui/src/components/JsonSchemaForm.test.tsx`
- `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19
passing
For an operator-side smoke test:
1. Get an exe.dev API token with `/exec` permission for `new`, `ls`,
`rm`.
2. Register the plugin in your Paperclip instance, configure an
environment with the token.
3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or
Claude job against it. The default Node 20 setup script should bring the
VM up automatically.
## Risks
- Adds a new sandbox provider plugin that follows the existing daytona /
e2b shape; behavior on existing providers is unchanged.
- The `JsonSchemaForm` textarea variant only engages for fields that opt
in via `maxLength` larger than the existing threshold. All existing
secret fields (which don't declare a `maxLength`) keep their current
rendering. Test coverage pins both paths.
- The redaction in `runLifecycleCommand` is a defense-in-depth measure;
the test suite exercises the redaction path. If the redaction misses a
future env-arg shape, the worst case is restored behavior (secrets in
error messages), which is what the existing daytona / e2b plugins also
do today.
- Default setup script downloads from `deb.nodesource.com` over HTTPS at
VM init. Operators on air-gapped networks or with a different package
strategy can override the setup script.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — UI change is a textarea variant of an existing secret
field; will attach screenshots before requesting merge
- [x] I have updated relevant documentation to reflect my changes
(plugin README, manifest descriptions)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 07:42:18 -07:00
function normalizeApiUrl ( value : string | null ) : string {
if ( ! value ) return DEFAULT_API_URL ;
const trimmed = value . trim ( ) ;
if ( ! trimmed ) return DEFAULT_API_URL ;
try {
const parsed = new URL ( trimmed ) ;
const normalizedPath = parsed . pathname . replace ( /\/+$/ , "" ) || "/" ;
if ( normalizedPath === "/exec" ) {
parsed . pathname = "/exec" ;
return parsed . toString ( ) ;
}
parsed . pathname = ` ${ normalizedPath === "/" ? "" : normalizedPath } /exec ` . replace ( /\/{2,}/g , "/" ) ;
return parsed . toString ( ) ;
} catch {
return trimmed ;
}
}
function normalizeNamePrefix ( value : string | null ) : string {
const normalized = ( value ? ? "paperclip" )
. toLowerCase ( )
. replace ( /[^a-z0-9-]+/g , "-" )
. replace ( /^-+|-+$/g , "" )
. replace ( /-{2,}/g , "-" ) ;
return normalized . length > 0 ? normalized . slice ( 0 , 24 ) : "paperclip" ;
}
function parseDriverConfig ( raw : Record < string , unknown > ) : ExeDevDriverConfig {
const timeoutMs = Number ( raw . timeoutMs ? ? DEFAULT_TIMEOUT_MS ) ;
const sshPort = Number ( raw . sshPort ? ? 22 ) ;
return {
apiKey : parseOptionalString ( raw . apiKey ) ,
apiUrl : normalizeApiUrl ( parseOptionalString ( raw . apiUrl ) ) ,
namePrefix : normalizeNamePrefix ( parseOptionalString ( raw . namePrefix ) ) ,
image : parseOptionalString ( raw . image ) ,
command : parseOptionalString ( raw . command ) ,
cpu : parseOptionalInteger ( raw . cpu ) ,
memory : parseOptionalString ( raw . memory ) ,
disk : parseOptionalString ( raw . disk ) ,
comment : parseOptionalString ( raw . comment ) ,
env : parseEnvMap ( raw . env ) ,
integrations : parseStringArray ( raw . integrations ) ,
tags : parseStringArray ( raw . tags ) ,
setupScript : parseOptionalString ( raw . setupScript ) ,
prompt : parseOptionalString ( raw . prompt ) ,
timeoutMs : Number.isFinite ( timeoutMs ) ? Math . trunc ( timeoutMs ) : DEFAULT_TIMEOUT_MS ,
reuseLease : raw.reuseLease === true ,
sshUser : parseOptionalString ( raw . sshUser ) ,
sshPrivateKey : parseOptionalString ( raw . sshPrivateKey ) ,
sshIdentityFile : parseOptionalString ( raw . sshIdentityFile ) ,
sshPort : Number.isFinite ( sshPort ) ? Math . trunc ( sshPort ) : 22 ,
strictHostKeyChecking : parseOptionalString ( raw . strictHostKeyChecking ) ? ? "accept-new" ,
} ;
}
function resolveApiKey ( config : ExeDevDriverConfig ) : string {
if ( config . apiKey ) return config . apiKey ;
const envApiKey = process . env . EXE_API_KEY ? . trim ( ) ? ? "" ;
if ( ! envApiKey ) {
throw new Error ( "exe.dev environments require an API key in config or EXE_API_KEY." ) ;
}
return envApiKey ;
}
function isValidShellEnvKey ( value : string ) : boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/ . test ( value ) ;
}
function shellQuote ( value : string ) : string {
return ` ' ${ value . replace ( /'/g , ` '"'"' ` ) } ' ` ;
}
function formatErrorMessage ( error : unknown ) : string {
return error instanceof Error ? error.message : String ( error ) ;
}
function buildVmName ( config : ExeDevDriverConfig , params : PluginEnvironmentAcquireLeaseParams ) : string {
const envPart = params . environmentId . replace ( /[^a-z0-9]+/gi , "" ) . slice ( 0 , 8 ) . toLowerCase ( ) || "env" ;
const runPart = params . runId . replace ( /[^a-z0-9]+/gi , "" ) . slice ( 0 , 8 ) . toLowerCase ( ) || randomUUID ( ) . slice ( 0 , 8 ) ;
return ` ${ config . namePrefix } - ${ envPart } - ${ runPart } ` . slice ( 0 , 63 ) ;
}
function buildFlag ( name : string , value : string | number | null | undefined ) : string [ ] {
if ( value == null ) return [ ] ;
return [ ` -- ${ name } = ${ shellQuote ( String ( value ) ) } ` ] ;
}
function buildRepeatedFlag ( name : string , values : string [ ] ) : string [ ] {
return values . flatMap ( ( value ) = > buildFlag ( name , value ) ) ;
}
function buildEnvFlags ( env : Record < string , string > ) : string [ ] {
return Object . entries ( env ) . flatMap ( ( [ key , value ] ) = > buildFlag ( "env" , ` ${ key } = ${ value } ` ) ) ;
}
function resolveSetupScript ( config : ExeDevDriverConfig ) : string | null {
if ( config . setupScript === null ) return DEFAULT_SETUP_SCRIPT ;
const trimmed = config . setupScript . trim ( ) ;
return trimmed . length > 0 ? config.setupScript : null ;
}
function buildCreateCommand (
config : ExeDevDriverConfig ,
vmName : string ,
) : string {
return [
"new" ,
"--json" ,
"--no-email" ,
. . . buildFlag ( "name" , vmName ) ,
. . . buildFlag ( "image" , config . image ) ,
. . . buildFlag ( "command" , config . command ) ,
. . . buildFlag ( "cpu" , config . cpu ) ,
. . . buildFlag ( "memory" , config . memory ) ,
. . . buildFlag ( "disk" , config . disk ) ,
. . . buildFlag ( "comment" , config . comment ) ,
. . . buildEnvFlags ( config . env ) ,
. . . buildRepeatedFlag ( "integration" , config . integrations ) ,
. . . buildRepeatedFlag ( "tag" , config . tags ) ,
. . . buildFlag ( "setup-script" , resolveSetupScript ( config ) ) ,
. . . buildFlag ( "prompt" , config . prompt ) ,
] . join ( " " ) ;
}
function replaceLiteralAll ( input : string , search : string , replacement : string ) : string {
return search . length === 0 ? input : input.split ( search ) . join ( replacement ) ;
}
function redactCreateCommand ( command : string , config : ExeDevDriverConfig ) : string {
let redacted = command ;
for ( const [ key , value ] of Object . entries ( config . env ) ) {
redacted = replaceLiteralAll (
redacted ,
` --env= ${ shellQuote ( ` ${ key } = ${ value } ` ) } ` ,
` --env= ${ shellQuote ( ` ${ key } =[REDACTED] ` ) } ` ,
) ;
}
if ( config . prompt ) {
redacted = replaceLiteralAll (
redacted ,
` --prompt= ${ shellQuote ( config . prompt ) } ` ,
` --prompt= ${ shellQuote ( "[REDACTED]" ) } ` ,
) ;
}
const resolvedSetupScript = resolveSetupScript ( config ) ;
if ( resolvedSetupScript && resolvedSetupScript !== DEFAULT_SETUP_SCRIPT ) {
redacted = replaceLiteralAll (
redacted ,
` --setup-script= ${ shellQuote ( resolvedSetupScript ) } ` ,
` --setup-script= ${ shellQuote ( "[REDACTED]" ) } ` ,
) ;
}
return redacted ;
}
async function runLifecycleCommand (
config : ExeDevDriverConfig ,
command : string ,
logCommand = command ,
) : Promise < unknown > {
const response = await fetch ( config . apiUrl , {
method : "POST" ,
headers : {
Authorization : ` Bearer ${ resolveApiKey ( config ) } ` ,
"Content-Type" : "text/plain; charset=utf-8" ,
} ,
body : command ,
signal : AbortSignal.timeout ( Math . min ( config . timeoutMs , EXE_DEV_API_MAX_TIMEOUT_MS ) ) ,
} ) ;
const body = await response . text ( ) ;
if ( ! response . ok ) {
throw new ExeDevApiError (
` exe.dev API command failed ( ${ response . status } ) for: ${ logCommand } ` ,
response . status ,
body ,
) ;
}
const trimmed = body . trim ( ) ;
if ( ! trimmed ) return null ;
try {
return JSON . parse ( trimmed ) ;
} catch {
return body ;
}
}
function parseVmRecord ( value : unknown , depth = 0 ) : ExeDevVmRecord | null {
if ( depth > MAX_VM_RECORD_DEPTH ) return null ;
if ( ! value || typeof value !== "object" || Array . isArray ( value ) ) return null ;
const record = value as Record < string , unknown > ;
const nested = parseVmRecord ( record . vm , depth + 1 ) ? ? parseVmRecord ( record . data , depth + 1 ) ;
if ( nested ) return nested ;
const name = parseOptionalString ( record . vm_name ? ? record . name ? ? record . vmName ) ;
const sshDest = parseOptionalString ( record . ssh_dest ? ? record . sshDest )
? ? ( name ? ` ${ name } .exe.xyz ` : null ) ;
if ( ! name || ! sshDest ) return null ;
return {
name ,
sshDest ,
httpsUrl : parseOptionalString ( record . https_url ? ? record . httpsUrl ) ,
status : parseOptionalString ( record . status ) ,
region : parseOptionalString ( record . region ) ,
regionDisplay : parseOptionalString ( record . region_display ? ? record . regionDisplay ) ,
} ;
}
async function lookupVm ( config : ExeDevDriverConfig , vmName : string ) : Promise < ExeDevVmRecord | null > {
const response = await runLifecycleCommand ( config , ` ls --json ${ shellQuote ( vmName ) } ` ) ;
const list = Array . isArray ( ( response as { vms? : unknown [ ] } | null ) ? . vms )
? ( response as { vms : unknown [ ] } ) . vms
: Array . isArray ( response )
? response
: response
? [ response ]
: [ ] ;
for ( const candidate of list ) {
const parsed = parseVmRecord ( candidate ) ;
if ( parsed ? . name === vmName || parsed ? . sshDest === vmName ) {
return parsed ;
}
}
return null ;
}
async function createVm (
config : ExeDevDriverConfig ,
params : PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams ,
) : Promise < ExeDevVmRecord > {
const vmName = "runId" in params
? buildVmName ( config , params )
: ` ${ config . namePrefix } -probe- ${ randomUUID ( ) . slice ( 0 , 8 ) } ` . slice ( 0 , 63 ) ;
const command = buildCreateCommand ( config , vmName ) ;
const response = await runLifecycleCommand ( config , command , redactCreateCommand ( command , config ) ) ;
const created = parseVmRecord ( response ) ? ? await lookupVm ( config , vmName ) ;
if ( ! created ) {
throw new Error ( ` exe.dev did not return VM metadata for ${ vmName } . ` ) ;
}
return created ;
}
async function deleteVm ( config : ExeDevDriverConfig , vmName : string ) : Promise < void > {
await runLifecycleCommand ( config , ` rm --json ${ shellQuote ( vmName ) } ` ) ;
}
function buildSshDestination ( config : ExeDevDriverConfig , vm : ExeDevVmRecord ) : string {
return config . sshUser ? ` ${ config . sshUser } @ ${ vm . sshDest } ` : vm . sshDest ;
}
function buildSshArgs (
config : ExeDevDriverConfig ,
vm : ExeDevVmRecord ,
remoteCommand : string ,
sshIdentityFile : string | null ,
) : string [ ] {
const args = [
"-T" ,
"-o" ,
"BatchMode=yes" ,
"-o" ,
` StrictHostKeyChecking= ${ config . strictHostKeyChecking } ` ,
"-o" ,
"ConnectTimeout=15" ,
"-p" ,
String ( config . sshPort ) ,
] ;
if ( sshIdentityFile ) {
args . push ( "-i" , sshIdentityFile , "-o" , "IdentitiesOnly=yes" ) ;
}
args . push ( buildSshDestination ( config , vm ) , remoteCommand ) ;
return args ;
}
async function prepareSshIdentity ( config : ExeDevDriverConfig ) : Promise < {
sshIdentityFile : string | null ;
cleanup : ( ) = > Promise < void > ;
} > {
if ( ! config . sshPrivateKey ) {
return {
sshIdentityFile : config.sshIdentityFile ,
cleanup : async ( ) = > { } ,
} ;
}
const tempDir = await mkdtemp ( path . join ( tmpdir ( ) , "paperclip-exe-dev-ssh-" ) ) ;
const sshIdentityFile = path . join ( tempDir , "id_ed25519" ) ;
const privateKey = config . sshPrivateKey . endsWith ( "\n" )
? config . sshPrivateKey
: ` ${ config . sshPrivateKey } \ n ` ;
await writeFile ( sshIdentityFile , privateKey , { mode : 0o600 } ) ;
await chmod ( sshIdentityFile , 0 o600 ) ;
return {
sshIdentityFile ,
cleanup : async ( ) = > {
await rm ( tempDir , { recursive : true , force : true } ) ;
} ,
} ;
}
function buildLoginShellScript ( input : {
command : string ;
args : string [ ] ;
cwd? : string ;
env? : Record < string , string > ;
} ) : string {
const env = input . env ? ? { } ;
for ( const key of Object . keys ( env ) ) {
if ( ! isValidShellEnvKey ( key ) ) {
throw new Error ( ` Invalid exe.dev 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 finalLine = envArgs . length > 0
? ` exec env ${ envArgs . join ( " " ) } ${ commandParts } `
: ` exec ${ commandParts } ` ;
const lines = [
'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' ,
'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' ,
] ;
if ( input . cwd ) {
lines . push ( ` cd ${ shellQuote ( input . cwd ) } ` ) ;
}
lines . push ( finalLine ) ;
return lines . join ( " && " ) ;
}
function formatSshFailure (
action : string ,
vmName : string ,
result : Pick < SshExecutionResult , "stdout" | "stderr" > ,
) : string {
const combinedOutput = ` ${ result . stderr } \ n ${ result . stdout } ` ;
if (
combinedOutput . includes ( EXE_DEV_SSH_ONBOARDING_MARKER )
|| combinedOutput . includes ( EXE_DEV_SSH_EMAIL_PROMPT )
) {
return [
` Failed to ${ action } exe.dev VM ${ vmName } : the Paperclip host SSH key is not registered with exe.dev. ` ,
"Complete exe.dev's one-time SSH onboarding on this host by running `ssh exe.dev` and following the email verification prompt, then retry." ,
] . join ( " " ) ;
}
exe.dev config UX: advanced-options disclosure, form-default fix, SSH key handling (PAPA-407) (#7025)
## Thinking Path
> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.
## What Changed
- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.
## Verification
CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.
Run locally:
```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.
# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```
Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):
1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).
## Risks
- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination
## Checklist
- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 18:19:37 -07:00
if ( EXE_DEV_SSH_INVALID_KEY_FORMAT . test ( combinedOutput ) ) {
return [
` Failed to ${ action } exe.dev VM ${ vmName } : the configured SSH private key isn't an OpenSSH-format private key. ` ,
"Confirm the secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub` file or a PuTTY `.ppk` export." ,
] . join ( " " ) ;
}
Add exe.dev sandbox provider plugin (#5688)
> _Stacked on top of #5685 → #5686 → #5687. Diff against master includes
commits from earlier PRs in the stack — review focuses on the two new
commits (`Add long-secret textarea variant to JsonSchemaForm
SecretField` + `Add exe.dev sandbox provider plugin`)._
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent runs in a sandbox environment, and operators choose the
provider — today E2B, Daytona, and (in this stack) Cloudflare
> - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful
for operators who want full Linux VMs (vs container/runtime-only
sandboxes)
> - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`,
`ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access
for adapters that need it
> - exe.dev VMs come up bare — `node` is not preinstalled, so the
Paperclip sandbox callback bridge (a Node script) needs Node 20
installed at VM init via `--setup-script`. The plugin defaults the setup
script to a Nodesource install
> - The auth field accepts long SSH private keys, which need a textarea
variant of the existing `SecretField` in `JsonSchemaForm` — added behind
a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected
> - The benefit is that operators get exe.dev as a fully working sandbox
provider out of the box, with no manual VM provisioning required
## What Changed
**Shared UI support (`Add long-secret textarea variant to JsonSchemaForm
SecretField`):**
- `ui/src/components/JsonSchemaForm.tsx` + new
`JsonSchemaForm.test.tsx`: when a secret-formatted field declares
`maxLength` larger than the existing single-line threshold, render a
monospace textarea instead of the masked input. Short secrets (API keys,
tokens) keep the existing masked-input + show/hide toggle behavior.
**The exe.dev plugin (`Add exe.dev sandbox provider plugin`):**
- `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest,
plugin runtime, README, and 19-test Vitest suite.
- Manifest fields: API token (with `secret-ref` + `/exec` permission
notes — needs `new`, `ls`, `rm`), API URL override, optional SSH
username, optional SSH private key (uses the new `JsonSchemaForm`
textarea variant via `maxLength: 4096`), optional SSH identity-file
path, optional setup script.
- Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs
come up bare and the Paperclip sandbox callback bridge is a Node script,
so without Node preinstalled the bridge can't start. Operators can
override by supplying their own setup script.
- `runLifecycleCommand` redacts env values from the executed command
before surfacing it in error messages, so secrets passed via
`--env=KEY=VALUE` don't leak into operator-visible failures.
- The plugin distinguishes exe.dev's SSH onboarding failures (`Please
complete registration by running: ssh exe.dev`) from general SSH
failures and surfaces a clear remediation message.
- `scripts/release-package-manifest.json`: register the new plugin for
CI publish alongside the existing daytona / e2b providers.
## Verification
- `pnpm typecheck`
- `pnpm exec vitest run --no-coverage
ui/src/components/JsonSchemaForm.test.tsx`
- `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19
passing
For an operator-side smoke test:
1. Get an exe.dev API token with `/exec` permission for `new`, `ls`,
`rm`.
2. Register the plugin in your Paperclip instance, configure an
environment with the token.
3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or
Claude job against it. The default Node 20 setup script should bring the
VM up automatically.
## Risks
- Adds a new sandbox provider plugin that follows the existing daytona /
e2b shape; behavior on existing providers is unchanged.
- The `JsonSchemaForm` textarea variant only engages for fields that opt
in via `maxLength` larger than the existing threshold. All existing
secret fields (which don't declare a `maxLength`) keep their current
rendering. Test coverage pins both paths.
- The redaction in `runLifecycleCommand` is a defense-in-depth measure;
the test suite exercises the redaction path. If the redaction misses a
future env-arg shape, the worst case is restored behavior (secrets in
error messages), which is what the existing daytona / e2b plugins also
do today.
- Default setup script downloads from `deb.nodesource.com` over HTTPS at
VM init. Operators on air-gapped networks or with a different package
strategy can override the setup script.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — UI change is a textarea variant of an existing secret
field; will attach screenshots before requesting merge
- [x] I have updated relevant documentation to reflect my changes
(plugin README, manifest descriptions)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 07:42:18 -07:00
return ` Failed to ${ action } exe.dev VM ${ vmName } : ${ result . stderr . trim ( ) || result . stdout . trim ( ) || "unknown error" } ` ;
}
async function runSshCommand (
config : ExeDevDriverConfig ,
vm : ExeDevVmRecord ,
remoteCommand : string ,
options : { stdin? : string ; timeoutMs? : number } = { } ,
) : Promise < SshExecutionResult > {
const timeoutMs = options . timeoutMs ? ? config . timeoutMs ;
const identity = await prepareSshIdentity ( config ) ;
try {
return await new Promise ( ( resolve , reject ) = > {
const child = spawn ( "ssh" , buildSshArgs ( config , vm , remoteCommand , identity . sshIdentityFile ) , {
stdio : [ options . stdin != null ? "pipe" : "ignore" , "pipe" , "pipe" ] ,
} ) ;
let stdout = "" ;
let stderr = "" ;
let timedOut = false ;
let killTimer : NodeJS.Timeout | null = null ;
const timer = timeoutMs > 0
? setTimeout ( ( ) = > {
timedOut = true ;
child . kill ( "SIGTERM" ) ;
killTimer = setTimeout ( ( ) = > {
child . kill ( "SIGKILL" ) ;
} , SSH_SIGKILL_GRACE_MS ) ;
} , timeoutMs )
: null ;
child . stdout ? . on ( "data" , ( chunk ) = > {
stdout += String ( chunk ) ;
} ) ;
child . stderr ? . on ( "data" , ( chunk ) = > {
stderr += String ( chunk ) ;
} ) ;
child . on ( "error" , ( error ) = > {
if ( timer ) clearTimeout ( timer ) ;
if ( killTimer ) clearTimeout ( killTimer ) ;
reject ( error ) ;
} ) ;
child . on ( "close" , ( code , signal ) = > {
if ( timer ) clearTimeout ( timer ) ;
if ( killTimer ) clearTimeout ( killTimer ) ;
resolve ( {
exitCode : timedOut ? null : code ,
signal ,
timedOut ,
stdout ,
stderr ,
} ) ;
} ) ;
if ( options . stdin != null && child . stdin ) {
child . stdin . write ( options . stdin ) ;
child . stdin . end ( ) ;
}
} ) ;
} finally {
await identity . cleanup ( ) ;
}
}
async function detectRemoteContext (
config : ExeDevDriverConfig ,
vm : ExeDevVmRecord ,
) : Promise < { homeDir : string ; shellCommand : "bash" | "sh" } > {
const result = await runSshCommand (
config ,
vm ,
` sh -lc ${ shellQuote (
'home="${HOME:-}"; if [ -z "$home" ]; then home="$(pwd)"; fi; if command -v bash >/dev/null 2>&1; then shell=bash; else shell=sh; fi; printf "%s\\n%s\\n" "$home" "$shell"' ,
) } ` ,
) ;
if ( result . timedOut || result . exitCode !== 0 ) {
throw new Error ( formatSshFailure ( "inspect" , vm . name , result ) ) ;
}
const [ homeDirRaw , shellRaw ] = result . stdout . split ( /\r?\n/ ) ;
const homeDir = homeDirRaw ? . trim ( ) || "/tmp" ;
return {
homeDir ,
shellCommand : shellRaw?.trim ( ) === "bash" ? "bash" : "sh" ,
} ;
}
async function ensureRemoteWorkspace (
config : ExeDevDriverConfig ,
vm : ExeDevVmRecord ,
remoteCwd : string ,
) : Promise < void > {
const result = await runSshCommand (
config ,
vm ,
` sh -lc ${ shellQuote ( ` mkdir -p ${ shellQuote ( remoteCwd ) } ` ) } ` ,
) ;
if ( result . timedOut || result . exitCode !== 0 ) {
throw new Error ( formatSshFailure ( "create workspace for" , vm . name , result ) ) ;
}
}
async function buildLease (
config : ExeDevDriverConfig ,
vm : ExeDevVmRecord ,
requestedCwd : string | undefined ,
resumedLease : boolean ,
) : Promise < PluginEnvironmentLease > {
const remote = await detectRemoteContext ( config , vm ) ;
const remoteCwd = requestedCwd ? . trim ( ) || path . posix . join ( remote . homeDir , "paperclip-workspace" ) ;
await ensureRemoteWorkspace ( config , vm , remoteCwd ) ;
return {
providerLeaseId : vm.name ,
metadata : {
provider : "exe-dev" ,
vmName : vm.name ,
sshDest : vm.sshDest ,
httpsUrl : vm.httpsUrl ,
region : vm.region ,
regionDisplay : vm.regionDisplay ,
shellCommand : remote.shellCommand ,
remoteCwd ,
timeoutMs : config.timeoutMs ,
reuseLease : config.reuseLease ,
resumedLease ,
} ,
} ;
}
function metadataVmRecord ( params : {
providerLeaseId : string | null ;
leaseMetadata? : Record < string , unknown > | null ;
} ) : ExeDevVmRecord | null {
if ( ! params . providerLeaseId ) return null ;
const sshDest = parseOptionalString ( params . leaseMetadata ? . sshDest ) ? ? ` ${ params . providerLeaseId } .exe.xyz ` ;
return {
name : params.providerLeaseId ,
sshDest ,
httpsUrl : parseOptionalString ( params . leaseMetadata ? . httpsUrl ) ,
status : parseOptionalString ( params . leaseMetadata ? . status ) ,
region : parseOptionalString ( params . leaseMetadata ? . region ) ,
regionDisplay : parseOptionalString ( params . leaseMetadata ? . regionDisplay ) ,
} ;
}
const plugin = definePlugin ( {
async setup ( ctx ) {
ctx . logger . info ( "exe.dev sandbox provider plugin ready" ) ;
} ,
async onHealth() {
return { status : "ok" , message : "exe.dev sandbox provider plugin healthy" } ;
} ,
async onEnvironmentValidateConfig (
params : PluginEnvironmentValidateConfigParams ,
) : Promise < PluginEnvironmentValidationResult > {
const config = parseDriverConfig ( params . config ) ;
const errors : string [ ] = [ ] ;
const warnings : string [ ] = [ ] ;
if ( config . apiUrl && ! isValidUrl ( config . apiUrl ) ) {
errors . push ( "apiUrl must be a valid URL." ) ;
}
if ( config . timeoutMs < 1 || config . timeoutMs > 86 _400_000 ) {
errors . push ( "timeoutMs must be between 1 and 86400000." ) ;
}
if ( config . cpu != null && config . cpu <= 0 ) {
errors . push ( "cpu must be greater than 0 when provided." ) ;
}
if ( config . sshPort < 1 || config . sshPort > 65 _535 ) {
errors . push ( "sshPort must be between 1 and 65535." ) ;
}
if ( ! config . apiKey && ! ( process . env . EXE_API_KEY ? . trim ( ) ) ) {
errors . push ( "exe.dev environments require an API key in config or EXE_API_KEY." ) ;
}
for ( const key of Object . keys ( config . env ) ) {
if ( ! isValidShellEnvKey ( key ) ) {
errors . push ( ` env contains an invalid key: ${ key } ` ) ;
}
}
if (
typeof params . config . strictHostKeyChecking === "string" &&
params . config . strictHostKeyChecking . trim ( ) . length === 0
) {
errors . push ( "strictHostKeyChecking cannot be empty." ) ;
}
exe.dev config UX: advanced-options disclosure, form-default fix, SSH key handling (PAPA-407) (#7025)
## Thinking Path
> - Paperclip orchestrates AI agents and provisions sandboxed execution
environments for them; one of those provisioners is the exe.dev plugin,
which runs each agent inside a long-lived VM reached over SSH.
> - The instance-config form for that plugin is rendered generically by
`JsonSchemaForm` from the plugin's `instanceConfigSchema`, so any UX
problem with the form is split between the shared form component and the
plugin's schema/runtime code.
> - Users coming in cold hit a 12-field flat config they couldn't reason
about (PAPA-407), a form that silently submitted `cpu: 0` for untouched
optional fields (PAPA-407 root cause), a `sshPrivateKey` textarea that
truncated RSA-4096 keys at 4096 chars (PAPA-449), a save flow that
accepted clearly-malformed keys and only blew up at lease time with raw
SSH stderr (PAPA-450, PAPA-451), and a manifest that didn't distinguish
"essential" from "advanced" knobs (PAPA-410 / PAPA-411 — duplicate
sub-issues with identical scope; PAPA-418 reconciliation kept PAPA-410
canonical).
> - These problems all point at the same surface (exe.dev sandbox
config) and are tightly coupled in code — PAPA-449/450/451 patch fields
that PAPA-410/411 introduce — so they get reviewed together.
> - This pull request lands the shared-form changes (advanced-options
disclosure, optional-scalar defaults) and the exe.dev-specific changes
(manifest restructure, longer `maxLength`, stderr translation, save-time
key validation) as five focused commits stacked on `master`.
> - The benefit is a config form that defaults to the two fields a new
user actually needs (API key + SSH private key) with a collapsible
disclosure for the rest, no silent truncation or zero-default
submissions, and SSH key problems surfaced at save time with actionable
messages instead of cryptic post-provision failures.
## What Changed
- **JsonSchemaForm advanced-options disclosure** (PAPA-410, PAPA-411 —
same scope, see note above): adds `x-paperclip-advanced` /
`x-paperclip-group` schema annotations and renders flagged fields behind
a collapsible "Advanced options" disclosure that auto-opens when a
hidden field has a validation error. Exe.dev manifest is restructured to
use the new annotations, so essentials (`apiKey`, `sshPrivateKey`) show
by default while the long tail of optional knobs is grouped under "SSH
access" / "VM resources" / "More options" headings.
- **Omit optional scalar defaults** (PAPA-407): `getDefaultForSchema` no
longer materialises `0` / `""` for optional
`number`/`integer`/`string`/`secret-ref` fields without an explicit
`default`. Object recursion drops properties whose default is
`undefined`. Fields that declare a `default` (e.g. `sshPort: 22`) still
round-trip. Adds a regression test against `getDefaultValues`.
- **Raise `sshPrivateKey` `maxLength`** (PAPA-449): bumps the exe.dev
manifest cap from 4096 to 8192 so RSA-4096 OpenSSH private keys (which
can exceed 4 KB with comments/metadata) aren't silently truncated at
submit.
- **Translate `invalid format` SSH stderr** (PAPA-450):
`formatSshFailure` now recognises `Load key … invalid format` in
combined stderr/stdout and returns a specific message naming the
key-format problem ("isn't an OpenSSH/PEM private key — confirm the
secret starts with `-----BEGIN … PRIVATE KEY-----` and isn't the `.pub`
or a PuTTY `.ppk` export") instead of dumping the raw stderr.
- **Save-time SSH key validation** (PAPA-451):
`onEnvironmentValidateConfig` inline-parses `sshPrivateKey` and rejects
common failure modes — pasted public keys, PuTTY `.ppk` format, missing
`-----END-----` footer, non-base64 body — so the form surfaces an inline
error before any VM is provisioned. Secret-ref bindings (UUIDs) are
still passed through unchanged.
## Verification
CI gates (`pnpm typecheck`, `pnpm test`, the targeted vitest suites
below) all pass.
Run locally:
```bash
# Shared form
pnpm --filter @paperclipai/ui exec vitest run src/components/JsonSchemaForm
# 9 tests pass — includes the new "omits optional scalar fields" regression
# and the three advanced-options-disclosure tests.
# exe.dev plugin
cd packages/plugins/sandbox-providers/exe-dev && pnpm test
# 32 tests pass — includes the new sshPrivateKey-validation cases
# and the new "invalid format" stderr-translation case.
```
Manual smoke (after reinstalling the plugin so the DB manifest
refreshes):
1. Open the exe.dev environment config page. **Default view shows API
Key + SSH Private Key only**, with an "Advanced options" disclosure for
everything else (PAPA-410 / PAPA-411).
2. Paste a `.pub` file's contents into SSH Private Key, click Save.
**Inline error** rejecting the wrong-format key (PAPA-451).
3. Re-paste a valid OpenSSH/PEM private key longer than 4096 bytes —
saves cleanly (PAPA-449).
4. Save the form with everything optional left blank — server no longer
rejects with `"cpu must be greater than 0 when provided"` (PAPA-407).
5. Force a bad key through via a stored secret-ref binding and lease a
VM — failure message names the key-format problem instead of dumping raw
SSH stderr (PAPA-450).
## Risks
- **PAPA-410 / PAPA-411 manifest restructure** is the largest surface
here. Schemas using `x-paperclip-*` extensions are forward-compatible
with stricter JSON Schema validators (extensions are ignored by
default), and the form gracefully renders a flat layout when no field
opts in.
- **PAPA-407** changes form-default behaviour: optional scalar fields
that previously round-tripped as `""` / `0` will now be `undefined` and
absent from the submitted payload. Downstream consumers that expected
the empty-string/zero shape need to treat the field as optional.
Spot-checked the existing exe.dev driver — it already uses
`parseOptionalString` / `parseOptionalInteger`, which treat missing
fields as `null` rather than `0`/`""`.
- **PAPA-451** adds a save-time check, so a
previously-saved-but-malformed `sshPrivateKey` raw value will now fail
to re-save. Bound secret-refs are unaffected, matching how the user
reaches the bad-key state today (via the secrets picker).
- **PAPA-449** simply raises a cap; no semantic risk.
- **PAPA-450** only kicks in on the "invalid format" code path; existing
onboarding-marker branch is untouched.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (`claude-opus-4-7`)
- Capabilities used: code reading, code editing, test execution, git/PR
mechanics, Paperclip API for issue coordination
## Checklist
- [x] PR body sections present (Thinking Path, What Changed,
Verification, Risks, Model Used, Checklist)
- [x] Unit tests added for the new behaviours (JsonSchemaForm
default-value omission + advanced disclosure; exe.dev plugin validation
+ stderr translation)
- [x] Existing tests still pass locally (`vitest run` on both packages)
- [x] No raw secrets, IP addresses, or machine-local config in commits
or PR body
- [x] Commits are atomic per linked issue (PAPA-410 / PAPA-411,
PAPA-407, PAPA-449, PAPA-450, PAPA-451)
- [x] Branch is up-to-date with `origin/master`
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-29 18:19:37 -07:00
if ( config . sshPrivateKey && ! isSecretRef ( config . sshPrivateKey ) ) {
const sshKeyError = validateSshPrivateKey ( config . sshPrivateKey ) ;
if ( sshKeyError ) errors . push ( sshKeyError ) ;
}
Add exe.dev sandbox provider plugin (#5688)
> _Stacked on top of #5685 → #5686 → #5687. Diff against master includes
commits from earlier PRs in the stack — review focuses on the two new
commits (`Add long-secret textarea variant to JsonSchemaForm
SecretField` + `Add exe.dev sandbox provider plugin`)._
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent runs in a sandbox environment, and operators choose the
provider — today E2B, Daytona, and (in this stack) Cloudflare
> - exe.dev offers per-VM sandboxes via a small CLI / HTTP API — useful
for operators who want full Linux VMs (vs container/runtime-only
sandboxes)
> - The plugin shape mirrors the e2b plugin: lifecycle hooks (`new`,
`ls`, `rm`) drive exe.dev's CLI; SSH plumbing handles direct VM access
for adapters that need it
> - exe.dev VMs come up bare — `node` is not preinstalled, so the
Paperclip sandbox callback bridge (a Node script) needs Node 20
installed at VM init via `--setup-script`. The plugin defaults the setup
script to a Nodesource install
> - The auth field accepts long SSH private keys, which need a textarea
variant of the existing `SecretField` in `JsonSchemaForm` — added behind
a `maxLength > THRESHOLD` opt-in so other secret fields are unaffected
> - The benefit is that operators get exe.dev as a fully working sandbox
provider out of the box, with no manual VM provisioning required
## What Changed
**Shared UI support (`Add long-secret textarea variant to JsonSchemaForm
SecretField`):**
- `ui/src/components/JsonSchemaForm.tsx` + new
`JsonSchemaForm.test.tsx`: when a secret-formatted field declares
`maxLength` larger than the existing single-line threshold, render a
monospace textarea instead of the masked input. Short secrets (API keys,
tokens) keep the existing masked-input + show/hide toggle behavior.
**The exe.dev plugin (`Add exe.dev sandbox provider plugin`):**
- `packages/plugins/sandbox-providers/exe-dev/`: plugin entry, manifest,
plugin runtime, README, and 19-test Vitest suite.
- Manifest fields: API token (with `secret-ref` + `/exec` permission
notes — needs `new`, `ls`, `rm`), API URL override, optional SSH
username, optional SSH private key (uses the new `JsonSchemaForm`
textarea variant via `maxLength: 4096`), optional SSH identity-file
path, optional setup script.
- Default `--setup-script` is a Nodesource Node 20 install. exe.dev VMs
come up bare and the Paperclip sandbox callback bridge is a Node script,
so without Node preinstalled the bridge can't start. Operators can
override by supplying their own setup script.
- `runLifecycleCommand` redacts env values from the executed command
before surfacing it in error messages, so secrets passed via
`--env=KEY=VALUE` don't leak into operator-visible failures.
- The plugin distinguishes exe.dev's SSH onboarding failures (`Please
complete registration by running: ssh exe.dev`) from general SSH
failures and surfaces a clear remediation message.
- `scripts/release-package-manifest.json`: register the new plugin for
CI publish alongside the existing daytona / e2b providers.
## Verification
- `pnpm typecheck`
- `pnpm exec vitest run --no-coverage
ui/src/components/JsonSchemaForm.test.tsx`
- `(cd packages/plugins/sandbox-providers/exe-dev && pnpm test)` — 19
passing
For an operator-side smoke test:
1. Get an exe.dev API token with `/exec` permission for `new`, `ls`,
`rm`.
2. Register the plugin in your Paperclip instance, configure an
environment with the token.
3. Create a sandbox env whose provider is `exe-dev`, then run a Codex or
Claude job against it. The default Node 20 setup script should bring the
VM up automatically.
## Risks
- Adds a new sandbox provider plugin that follows the existing daytona /
e2b shape; behavior on existing providers is unchanged.
- The `JsonSchemaForm` textarea variant only engages for fields that opt
in via `maxLength` larger than the existing threshold. All existing
secret fields (which don't declare a `maxLength`) keep their current
rendering. Test coverage pins both paths.
- The redaction in `runLifecycleCommand` is a defense-in-depth measure;
the test suite exercises the redaction path. If the redaction misses a
future env-arg shape, the worst case is restored behavior (secrets in
error messages), which is what the existing daytona / e2b plugins also
do today.
- Default setup script downloads from `deb.nodesource.com` over HTTPS at
VM init. Operators on air-gapped networks or with a different package
strategy can override the setup script.
## Model Used
- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep)
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — UI change is a textarea variant of an existing secret
field; will attach screenshots before requesting merge
- [x] I have updated relevant documentation to reflect my changes
(plugin README, manifest descriptions)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 07:42:18 -07:00
warnings . push (
"The Paperclip host must have SSH access to the created exe.dev VM, and its SSH key must be registered with exe.dev. The API token only covers provisioning." ,
) ;
if ( config . reuseLease ) {
warnings . push ( "reuseLease keeps the VM alive between runs; this provider does not suspend retained VMs." ) ;
}
if ( errors . length > 0 ) {
return { ok : false , errors , warnings } ;
}
return {
ok : true ,
warnings ,
normalizedConfig : { . . . config } ,
} ;
} ,
async onEnvironmentProbe (
params : PluginEnvironmentProbeParams ,
) : Promise < PluginEnvironmentProbeResult > {
const config = parseDriverConfig ( params . config ) ;
let vm : ExeDevVmRecord | null = null ;
try {
vm = await createVm ( config , params ) ;
const lease = await buildLease ( config , vm , undefined , false ) ;
return {
ok : true ,
summary : ` Connected to exe.dev VM ${ vm . name } . ` ,
metadata : {
provider : "exe-dev" ,
vmName : vm.name ,
sshDest : vm.sshDest ,
timeoutMs : config.timeoutMs ,
reuseLease : config.reuseLease ,
remoteCwd : lease.metadata?.remoteCwd ,
shellCommand : lease.metadata?.shellCommand ,
} ,
} ;
} catch ( error ) {
return {
ok : false ,
summary : "exe.dev environment probe failed." ,
metadata : {
provider : "exe-dev" ,
timeoutMs : config.timeoutMs ,
reuseLease : config.reuseLease ,
error : formatErrorMessage ( error ) ,
} ,
} ;
} finally {
if ( vm ) {
await deleteVm ( config , vm . name ) . catch ( ( ) = > undefined ) ;
}
}
} ,
async onEnvironmentAcquireLease (
params : PluginEnvironmentAcquireLeaseParams ,
) : Promise < PluginEnvironmentLease > {
const config = parseDriverConfig ( params . config ) ;
const vm = await createVm ( config , params ) ;
try {
return await buildLease ( config , vm , params . requestedCwd , false ) ;
} catch ( error ) {
await deleteVm ( config , vm . name ) . catch ( ( ) = > undefined ) ;
throw error ;
}
} ,
async onEnvironmentResumeLease (
params : PluginEnvironmentResumeLeaseParams ,
) : Promise < PluginEnvironmentLease > {
const config = parseDriverConfig ( params . config ) ;
const vm = await lookupVm ( config , params . providerLeaseId ) ;
if ( ! vm ) {
return { providerLeaseId : null , metadata : { expired : true } } ;
}
const requestedCwd = parseOptionalString ( params . leaseMetadata ? . remoteCwd ) ;
return await buildLease ( config , vm , requestedCwd ? ? undefined , true ) ;
} ,
async onEnvironmentReleaseLease (
params : PluginEnvironmentReleaseLeaseParams ,
) : Promise < void > {
if ( ! params . providerLeaseId ) return ;
const config = parseDriverConfig ( params . config ) ;
if ( config . reuseLease ) return ;
await deleteVm ( config , params . providerLeaseId ) ;
} ,
async onEnvironmentDestroyLease (
params : PluginEnvironmentDestroyLeaseParams ,
) : Promise < void > {
if ( ! params . providerLeaseId ) return ;
const config = parseDriverConfig ( params . config ) ;
await deleteVm ( config , params . providerLeaseId ) ;
} ,
async onEnvironmentRealizeWorkspace (
params : PluginEnvironmentRealizeWorkspaceParams ,
) : Promise < PluginEnvironmentRealizeWorkspaceResult > {
const config = parseDriverConfig ( params . config ) ;
const remoteCwd =
parseOptionalString ( params . lease . metadata ? . remoteCwd )
? ? params . workspace . remotePath
? ? params . workspace . localPath
? ? "/tmp/paperclip-workspace" ;
const vm = metadataVmRecord ( {
providerLeaseId : params.lease.providerLeaseId ,
leaseMetadata : params.lease.metadata ,
} ) ;
if ( vm ) {
await ensureRemoteWorkspace ( config , vm , remoteCwd ) ;
}
return {
cwd : remoteCwd ,
metadata : {
provider : "exe-dev" ,
remoteCwd ,
} ,
} ;
} ,
async onEnvironmentExecute (
params : PluginEnvironmentExecuteParams ,
) : Promise < PluginEnvironmentExecuteResult > {
if ( ! params . lease . providerLeaseId ) {
return {
exitCode : 1 ,
signal : null ,
timedOut : false ,
stdout : "" ,
stderr : "No provider lease ID available for execution." ,
} ;
}
const config = parseDriverConfig ( params . config ) ;
const vm = metadataVmRecord ( {
providerLeaseId : params.lease.providerLeaseId ,
leaseMetadata : params.lease.metadata ,
} ) ;
if ( ! vm ) {
return {
exitCode : 1 ,
signal : null ,
timedOut : false ,
stdout : "" ,
stderr : "No exe.dev VM metadata available for execution." ,
} ;
}
const command = buildLoginShellScript ( {
command : params.command ,
args : params.args ? ? [ ] ,
cwd : params.cwd ? ? parseOptionalString ( params . lease . metadata ? . remoteCwd ) ? ? undefined ,
env : params.env ,
} ) ;
// `buildLoginShellScript` already explicitly sources `/etc/profile`,
// `~/.profile`, `~/.bash_profile`/`~/.bashrc`, and `~/.zprofile`. Wrapping
// the result in `sh -lc` (login shell) would source the same files a
// second time, which can cause `PATH` duplication or unexpected behavior
// on VMs whose profile init isn't idempotent. Use `sh -c` here so the
// explicit sourcing inside the script is the single source of truth.
const result = await runSshCommand (
config ,
vm ,
` sh -c ${ shellQuote ( command ) } ` ,
{ stdin : params.stdin , timeoutMs : params.timeoutMs ? ? config . timeoutMs } ,
) ;
return {
exitCode : result.exitCode ,
signal : result.signal ,
timedOut : result.timedOut ,
stdout : result.stdout ,
stderr :
! result . timedOut && result . exitCode !== 0
? formatSshFailure ( "execute commands on" , vm . name , result )
: result . stderr ,
metadata : {
provider : "exe-dev" ,
vmName : vm.name ,
sshDest : vm.sshDest ,
} ,
} ;
} ,
} ) ;
export default plugin ;