Add Novita sandbox provider plugin (#7595)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip already separates agent adapters from execution environments, so agents can run locally, over SSH, or through sandbox providers. > - Sandbox provider plugins let Paperclip add new cloud runtimes without changing each agent adapter. > - Novita Agent Sandbox is a cloud runtime for AI agent workloads with isolated filesystems, command execution, templates, timeout controls, and pause/resume behavior. > - Paperclip currently has sandbox provider examples for Daytona and Cloudflare, but not Novita. > - This pull request adds a Novita sandbox provider plugin using the existing provider-plugin lifecycle. > - The benefit is that Paperclip users can run existing adapters such as Codex, Claude, Gemini, OpenCode, Cursor, or ACPX inside Novita Agent Sandbox environments. ## Linked Issues or Issue Description Fixes #7596 ## What Changed - Added `packages/plugins/sandbox-providers/novita` as a standalone sandbox provider plugin package. - Registered provider key `novita` with `kind: "sandbox_provider"` and `environment.drivers.register` capability. - Implemented Novita environment lifecycle hooks: validate config, probe, acquire lease, resume lease, release lease, destroy lease, realize workspace, and execute commands. - Added config support for `apiKey`, `domain`, `template`, `requestedCwd`, `timeoutMs`, `requestTimeoutMs`, `secure`, `autoPause`, and `reuseLease`. - Added README documentation for setup, configuration, and lifecycle behavior. - Added tests for manifest shape, config parsing, safe shell command wrapping, stdin delimiter safety, and env-key validation. ## Verification From `packages/plugins/sandbox-providers/novita`: - `pnpm typecheck` - `pnpm test` The tests avoid live Novita API calls and cover the provider's static contract and command-wrapping behavior. Live end-to-end verification requires a Paperclip instance with the plugin installed and a Novita API key configured as either a Paperclip secret or `NOVITA_API_KEY` in the worker environment. ## Risks - This adds a new direct dependency on the Novita Sandbox JS SDK (`novita-sandbox`). Socket/Snyk should review the package as part of normal dependency checks. - The implementation relies on Novita SDK command execution semantics; live provider behavior should be verified with a real Novita sandbox before marking the plugin production-ready. - `reuseLease` maps Paperclip release behavior to Novita `betaPause()`. If pause is unavailable for a selected template, the plugin falls back to best-effort kill during release. - Low migration risk for existing users because this is a new standalone provider plugin and does not change existing adapters or built-in providers. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex via Codex CLI, with repository file access, shell command execution, GitHub CLI/API usage, and local TypeScript/Vitest verification. Web and local documentation context were used for Novita Sandbox SDK/API behavior. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
@@ -380,7 +380,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
- ✅ Better Budgeting
|
||||
- ✅ Agent Reviews and Approvals
|
||||
- ✅ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
|
||||
- ⚪ Artifacts & Work Products
|
||||
- ⚪ Memory / Knowledge
|
||||
- ⚪ Enforced Outcomes
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Paperclip should support explicit review and approval stages as first-class work
|
||||
|
||||
Paperclip needs a clearer path from solo operator to real human teams. That means shared board access, safer collaboration, and a better model for several humans supervising the same autonomous company.
|
||||
|
||||
### ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
### ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
|
||||
|
||||
We want agents to run in more remote and sandboxed environments while preserving the same Paperclip control-plane model. This makes the system safer, more flexible, and more useful outside a single trusted local machine.
|
||||
|
||||
|
||||
+1
-1
@@ -379,7 +379,7 @@ See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc
|
||||
- ✅ Better Budgeting
|
||||
- ✅ Agent Reviews and Approvals
|
||||
- ✅ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
|
||||
- ⚪ Artifacts & Work Products
|
||||
- ⚪ Memory / Knowledge
|
||||
- ⚪ Enforced Outcomes
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# `@paperclipai/plugin-novita-sandbox`
|
||||
|
||||
Published Novita Agent Sandbox provider plugin for Paperclip.
|
||||
|
||||
This package lives in the Paperclip monorepo, but it is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. That means operators can install it from the Plugins page by package name, and the host will fetch its transitive dependencies at install time without adding lockfile churn to the Paperclip repo.
|
||||
|
||||
## Install
|
||||
|
||||
From a Paperclip instance, install:
|
||||
|
||||
```text
|
||||
@paperclipai/plugin-novita-sandbox
|
||||
```
|
||||
|
||||
The host plugin installer runs `npm install` into the managed plugin directory, so package dependencies such as `novita-sandbox` are pulled in during installation.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure Novita from `Company Settings -> Environments`, not from the plugin's instance settings page.
|
||||
|
||||
- Put the Novita API key on the sandbox environment itself.
|
||||
- When you save an environment, Paperclip stores pasted API keys as company secrets.
|
||||
- `NOVITA_API_KEY` remains an optional host-level fallback when an environment omits the key.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd packages/plugins/sandbox-providers/novita
|
||||
pnpm install --ignore-workspace --no-lockfile
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
These commands assume the repo root has already been installed once so the local `@paperclipai/plugin-sdk` workspace package is available to the compiler during development.
|
||||
|
||||
## Package layout
|
||||
|
||||
- `src/manifest.ts` declares the sandbox-provider driver metadata
|
||||
- `src/plugin.ts` implements the environment lifecycle hooks
|
||||
- `src/worker.ts` boots the plugin under the host worker runtime
|
||||
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-novita-sandbox",
|
||||
"version": "0.1.0",
|
||||
"description": "Novita Agent Sandbox provider plugin for Paperclip environments",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/sandbox-providers/novita"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"sandbox",
|
||||
"novita",
|
||||
"novita-ai"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"test": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && vitest run --config vitest.config.ts",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"novita-sandbox": "^1.0.3-b2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./plugin.js";
|
||||
export { default as manifest } from "./manifest.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.novita-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Novita Sandbox Provider",
|
||||
description:
|
||||
"Sandbox provider plugin that provisions Novita Agent Sandbox environments for Paperclip agent runs.",
|
||||
author: "Novita AI",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "novita",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Novita Agent Sandbox",
|
||||
description:
|
||||
"Provisions Novita Agent Sandbox instances with configurable templates, idle timeout, workspace path, and lease reuse.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
description:
|
||||
"Environment-specific Novita API key. Paste a key or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Falls back to NOVITA_API_KEY if omitted.",
|
||||
},
|
||||
domain: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional Novita API domain override. Leave empty to use the SDK default.",
|
||||
},
|
||||
template: {
|
||||
type: "string",
|
||||
description:
|
||||
"Novita sandbox template ID or name. Leave blank to use the SDK's default base template.",
|
||||
},
|
||||
requestedCwd: {
|
||||
type: "string",
|
||||
default: "/home/user/paperclip-workspace",
|
||||
description: "Workspace directory to create inside the sandbox lease.",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
default: 300000,
|
||||
description:
|
||||
"Sandbox lifetime and default per-command timeout in milliseconds.",
|
||||
},
|
||||
requestTimeoutMs: {
|
||||
type: "number",
|
||||
default: 30000,
|
||||
description:
|
||||
"HTTP/RPC request timeout for Novita SDK calls in milliseconds.",
|
||||
},
|
||||
secure: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Use secure connections when supported by the Novita SDK.",
|
||||
},
|
||||
autoPause: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Enable Novita sandbox auto-pause behavior when supported by the selected template.",
|
||||
},
|
||||
reuseLease: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description:
|
||||
"Pause and later resume the sandbox across Paperclip runs instead of killing it on release.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import manifest from "./manifest.js";
|
||||
|
||||
const mockSandboxConnect = vi.hoisted(() => vi.fn());
|
||||
const mockSandboxCreate = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("novita-sandbox", () => ({
|
||||
Sandbox: {
|
||||
connect: mockSandboxConnect,
|
||||
create: mockSandboxCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
import plugin, { buildShellCommand, parseNovitaDriverConfig } from "./plugin.js";
|
||||
|
||||
describe("Novita sandbox provider plugin", () => {
|
||||
it("declares a sandbox provider environment driver", () => {
|
||||
expect(manifest.capabilities).toContain("environment.drivers.register");
|
||||
expect(manifest.environmentDrivers).toHaveLength(1);
|
||||
expect(manifest.environmentDrivers?.[0]).toMatchObject({
|
||||
driverKey: "novita",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Novita Agent Sandbox",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses defaults", () => {
|
||||
expect(parseNovitaDriverConfig({})).toMatchObject({
|
||||
apiKey: null,
|
||||
domain: null,
|
||||
template: null,
|
||||
requestedCwd: "/home/user/paperclip-workspace",
|
||||
timeoutMs: 300_000,
|
||||
requestTimeoutMs: 30_000,
|
||||
secure: null,
|
||||
autoPause: false,
|
||||
reuseLease: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses configured values", () => {
|
||||
expect(parseNovitaDriverConfig({
|
||||
apiKey: "sk-test",
|
||||
domain: "https://sandbox.example.test",
|
||||
template: "paperclip-template",
|
||||
requestedCwd: "/workspace",
|
||||
timeoutMs: 600000,
|
||||
requestTimeoutMs: 45000,
|
||||
secure: true,
|
||||
autoPause: true,
|
||||
reuseLease: true,
|
||||
})).toMatchObject({
|
||||
apiKey: "sk-test",
|
||||
domain: "https://sandbox.example.test",
|
||||
template: "paperclip-template",
|
||||
requestedCwd: "/workspace",
|
||||
timeoutMs: 600_000,
|
||||
requestTimeoutMs: 45_000,
|
||||
secure: true,
|
||||
autoPause: true,
|
||||
reuseLease: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a quoted shell command with cwd, env, args, and stdin", () => {
|
||||
const command = buildShellCommand({
|
||||
command: "node",
|
||||
args: ["-e", "console.log(process.env.MESSAGE)"],
|
||||
cwd: "/workspace/project",
|
||||
env: { MESSAGE: "hello world" },
|
||||
stdin: "input body",
|
||||
});
|
||||
|
||||
expect(command).toContain("cd '/workspace/project'");
|
||||
expect(command).toContain("export MESSAGE='hello world';");
|
||||
expect(command).toContain("'node' '-e' 'console.log(process.env.MESSAGE)'");
|
||||
expect(command).toContain("printf '%s' 'input body' > '/tmp/.paperclip-stdin-");
|
||||
expect(command).toMatch(/< '\/tmp\/\.paperclip-stdin-[^']+'/);
|
||||
expect(command).toMatch(/rm -f '\/tmp\/\.paperclip-stdin-[^']+'/);
|
||||
expect(command).toContain("exit $status");
|
||||
});
|
||||
|
||||
it("does not use a heredoc delimiter for stdin", () => {
|
||||
const command = buildShellCommand({
|
||||
command: "cat",
|
||||
stdin: "before\nPAPERCLIP_STDIN\nafter",
|
||||
});
|
||||
|
||||
expect(command).toContain("before\nPAPERCLIP_STDIN\nafter");
|
||||
expect(command).not.toContain("<<");
|
||||
});
|
||||
|
||||
it("uses a unique stdin path for each command", () => {
|
||||
const first = buildShellCommand({
|
||||
command: "cat",
|
||||
stdin: "first",
|
||||
});
|
||||
const second = buildShellCommand({
|
||||
command: "cat",
|
||||
stdin: "second",
|
||||
});
|
||||
const firstPath = first.match(/\/tmp\/\.paperclip-stdin-[^']+/)?.[0];
|
||||
const secondPath = second.match(/\/tmp\/\.paperclip-stdin-[^']+/)?.[0];
|
||||
|
||||
expect(firstPath).toBeTruthy();
|
||||
expect(secondPath).toBeTruthy();
|
||||
expect(firstPath).not.toBe(secondPath);
|
||||
expect(first).not.toContain("/tmp/.paperclip-stdin <<");
|
||||
expect(second).not.toContain("/tmp/.paperclip-stdin <<");
|
||||
});
|
||||
|
||||
it("rejects unsafe environment variable keys", () => {
|
||||
expect(() => buildShellCommand({
|
||||
command: "env",
|
||||
env: { "BAD-KEY": "value" },
|
||||
})).toThrow("Invalid sandbox environment variable key");
|
||||
});
|
||||
|
||||
it("returns a command failure when execute is called for an expired sandbox lease", async () => {
|
||||
mockSandboxConnect.mockRejectedValueOnce(new Error("sandbox not found"));
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
issueId: "issue-1",
|
||||
runId: "run-1",
|
||||
config: { apiKey: "sk-test" },
|
||||
lease: { providerLeaseId: "sb-expired", metadata: {} },
|
||||
command: "true",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Novita sandbox lease is no longer available.\n",
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
sandboxId: "sb-expired",
|
||||
expired: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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";
|
||||
import { Sandbox } from "novita-sandbox";
|
||||
|
||||
export interface NovitaDriverConfig {
|
||||
apiKey: string | null;
|
||||
domain: string | null;
|
||||
template: string | null;
|
||||
requestedCwd: string;
|
||||
timeoutMs: number;
|
||||
requestTimeoutMs: number;
|
||||
secure: boolean | null;
|
||||
autoPause: boolean;
|
||||
reuseLease: boolean;
|
||||
}
|
||||
|
||||
function parseOptionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown, fallback: number): number {
|
||||
if (value == null || value === "") return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
|
||||
}
|
||||
|
||||
export function parseNovitaDriverConfig(raw: Record<string, unknown>): NovitaDriverConfig {
|
||||
return {
|
||||
apiKey: parseOptionalString(raw.apiKey),
|
||||
domain: parseOptionalString(raw.domain),
|
||||
template: parseOptionalString(raw.template),
|
||||
requestedCwd: parseOptionalString(raw.requestedCwd) ?? "/home/user/paperclip-workspace",
|
||||
timeoutMs: parsePositiveInteger(raw.timeoutMs, 300_000),
|
||||
requestTimeoutMs: parsePositiveInteger(raw.requestTimeoutMs, 30_000),
|
||||
secure: typeof raw.secure === "boolean" ? raw.secure : null,
|
||||
autoPause: raw.autoPause === true,
|
||||
reuseLease: raw.reuseLease === true,
|
||||
};
|
||||
}
|
||||
|
||||
function validateNovitaDriverConfig(config: NovitaDriverConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!config.apiKey && !process.env.NOVITA_API_KEY?.trim()) {
|
||||
errors.push("Novita sandbox environments require an API key in config or NOVITA_API_KEY.");
|
||||
}
|
||||
if (!config.requestedCwd.startsWith("/")) {
|
||||
errors.push("requestedCwd must be an absolute path.");
|
||||
}
|
||||
if (config.timeoutMs < 10_000) {
|
||||
errors.push("timeoutMs must be at least 10000.");
|
||||
}
|
||||
if (config.requestTimeoutMs < 1_000) {
|
||||
errors.push("requestTimeoutMs must be at least 1000.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function resolveApiKey(config: NovitaDriverConfig): string {
|
||||
const apiKey = config.apiKey ?? process.env.NOVITA_API_KEY?.trim() ?? "";
|
||||
if (!apiKey) {
|
||||
throw new Error("Novita sandbox environments require an API key in config or NOVITA_API_KEY.");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function sandboxOpts(config: NovitaDriverConfig) {
|
||||
return {
|
||||
apiKey: resolveApiKey(config),
|
||||
...(config.domain ? { domain: config.domain } : {}),
|
||||
...(config.secure == null ? {} : { secure: config.secure }),
|
||||
timeoutMs: config.timeoutMs,
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function singleQuoteForPrintf(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function isValidShellEnvKey(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
function buildStdinPath(): string {
|
||||
return `/tmp/.paperclip-stdin-${randomUUID()}`;
|
||||
}
|
||||
|
||||
export function buildShellCommand(input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
}): string {
|
||||
const envEntries = Object.entries(input.env ?? {});
|
||||
for (const [key] of envEntries) {
|
||||
if (!isValidShellEnvKey(key)) {
|
||||
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
const exports = envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`);
|
||||
const argv = [input.command, ...(input.args ?? [])].map(shellQuote).join(" ");
|
||||
const stdinPath = typeof input.stdin === "string" ? buildStdinPath() : null;
|
||||
const stdin = stdinPath ? `printf '%s' ${singleQuoteForPrintf(input.stdin ?? "")} > ${shellQuote(stdinPath)}` : "";
|
||||
const cwd = input.cwd?.trim() || "/";
|
||||
const commandLines = stdinPath
|
||||
? [
|
||||
"set +e",
|
||||
`${argv} < ${shellQuote(stdinPath)}`,
|
||||
"status=$?",
|
||||
`rm -f ${shellQuote(stdinPath)}`,
|
||||
"exit $status",
|
||||
]
|
||||
: [`exec ${argv}`];
|
||||
return [
|
||||
"set -e",
|
||||
stdin,
|
||||
`cd ${shellQuote(cwd)}`,
|
||||
...exports,
|
||||
...commandLines,
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
async function createSandbox(params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams, config: NovitaDriverConfig) {
|
||||
const metadata = {
|
||||
"paperclip-provider": "novita",
|
||||
"paperclip-company-id": params.companyId,
|
||||
"paperclip-environment-id": params.environmentId,
|
||||
...(params.issueId ? { "paperclip-issue-id": params.issueId } : {}),
|
||||
...("runId" in params ? { "paperclip-run-id": params.runId } : {}),
|
||||
};
|
||||
const opts = {
|
||||
...sandboxOpts(config),
|
||||
metadata,
|
||||
autoPause: config.autoPause,
|
||||
};
|
||||
return config.template
|
||||
? await Sandbox.create(config.template, opts)
|
||||
: await Sandbox.create(opts);
|
||||
}
|
||||
|
||||
async function connectSandbox(config: NovitaDriverConfig, sandboxId: string) {
|
||||
return await Sandbox.connect(sandboxId, sandboxOpts(config));
|
||||
}
|
||||
|
||||
function isSandboxNotFoundError(error: unknown): boolean {
|
||||
const message = formatErrorMessage(error).toLowerCase();
|
||||
return message.includes("not found") || message.includes("404");
|
||||
}
|
||||
|
||||
async function getSandboxOrNull(config: NovitaDriverConfig, sandboxId: string) {
|
||||
try {
|
||||
return await connectSandbox(config, sandboxId);
|
||||
} catch (error) {
|
||||
if (isSandboxNotFoundError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectShellCommand(sandbox: Sandbox, config: NovitaDriverConfig): Promise<"bash" | "sh"> {
|
||||
try {
|
||||
const result = await sandbox.commands.run(
|
||||
"if command -v bash >/dev/null 2>&1; then printf bash; else printf sh; fi",
|
||||
{ cwd: "/", timeoutMs: config.requestTimeoutMs },
|
||||
);
|
||||
return result.stdout.trim() === "bash" ? "bash" : "sh";
|
||||
} catch {
|
||||
return "sh";
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorkspace(sandbox: Sandbox, remoteCwd: string, config: NovitaDriverConfig) {
|
||||
await sandbox.commands.run(`mkdir -p ${shellQuote(remoteCwd)}`, {
|
||||
cwd: "/",
|
||||
timeoutMs: config.requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function leaseMetadata(input: {
|
||||
config: NovitaDriverConfig;
|
||||
sandbox: Sandbox;
|
||||
shellCommand: "bash" | "sh";
|
||||
remoteCwd: string;
|
||||
resumedLease: boolean;
|
||||
}) {
|
||||
return {
|
||||
provider: "novita",
|
||||
shellCommand: input.shellCommand,
|
||||
sandboxId: input.sandbox.sandboxId,
|
||||
template: input.config.template,
|
||||
timeoutMs: input.config.timeoutMs,
|
||||
requestTimeoutMs: input.config.requestTimeoutMs,
|
||||
autoPause: input.config.autoPause,
|
||||
reuseLease: input.config.reuseLease,
|
||||
remoteCwd: input.remoteCwd,
|
||||
resumedLease: input.resumedLease,
|
||||
};
|
||||
}
|
||||
|
||||
async function releaseSandbox(config: NovitaDriverConfig, sandboxId: string) {
|
||||
const sandbox = await getSandboxOrNull(config, sandboxId);
|
||||
if (!sandbox) return;
|
||||
if (config.reuseLease) {
|
||||
await sandbox.betaPause({ requestTimeoutMs: config.requestTimeoutMs }).catch(async () => {
|
||||
await sandbox.kill({ requestTimeoutMs: config.requestTimeoutMs }).catch(() => undefined);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await sandbox.kill({ requestTimeoutMs: config.requestTimeoutMs }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function executeInSandbox(
|
||||
sandbox: Sandbox,
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
config: NovitaDriverConfig,
|
||||
): Promise<PluginEnvironmentExecuteResult> {
|
||||
const command = buildShellCommand({
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
stdin: params.stdin,
|
||||
});
|
||||
try {
|
||||
const result = await sandbox.commands.run(command, {
|
||||
cwd: "/",
|
||||
timeoutMs: params.timeoutMs ?? config.timeoutMs,
|
||||
});
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
sandboxId: sandbox.sandboxId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const commandError = error as {
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
timedOut?: boolean;
|
||||
};
|
||||
if (typeof commandError.exitCode === "number" || commandError.timedOut === true) {
|
||||
return {
|
||||
exitCode: commandError.exitCode ?? 124,
|
||||
signal: null,
|
||||
timedOut: commandError.timedOut === true,
|
||||
stdout: commandError.stdout ?? "",
|
||||
stderr: commandError.stderr ?? commandError.error ?? "",
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
sandboxId: sandbox.sandboxId,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("Novita sandbox provider plugin ready");
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Novita sandbox provider plugin healthy" };
|
||||
},
|
||||
|
||||
async onEnvironmentValidateConfig(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
): Promise<PluginEnvironmentValidationResult> {
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
const errors = validateNovitaDriverConfig(config);
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
normalizedConfig: { ...config },
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentProbe(
|
||||
params: PluginEnvironmentProbeParams,
|
||||
): Promise<PluginEnvironmentProbeResult> {
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
try {
|
||||
const sandbox = await createSandbox(params, config);
|
||||
try {
|
||||
await ensureWorkspace(sandbox, config.requestedCwd, config);
|
||||
const shellCommand = await detectShellCommand(sandbox, config);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Connected to Novita sandbox ${sandbox.sandboxId}.`,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
shellCommand,
|
||||
remoteCwd: config.requestedCwd,
|
||||
resumedLease: false,
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
await sandbox.kill({ requestTimeoutMs: config.requestTimeoutMs }).catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: "Novita sandbox probe failed.",
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
template: config.template,
|
||||
timeoutMs: config.timeoutMs,
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
reuseLease: config.reuseLease,
|
||||
error: formatErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentAcquireLease(
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
const sandbox = await createSandbox(params, config);
|
||||
try {
|
||||
const remoteCwd = params.requestedCwd?.trim() || config.requestedCwd;
|
||||
await ensureWorkspace(sandbox, remoteCwd, config);
|
||||
const shellCommand = await detectShellCommand(sandbox, config);
|
||||
return {
|
||||
providerLeaseId: sandbox.sandboxId,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
shellCommand,
|
||||
remoteCwd,
|
||||
resumedLease: false,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
await sandbox.kill({ requestTimeoutMs: config.requestTimeoutMs }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentResumeLease(
|
||||
params: PluginEnvironmentResumeLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
if (!params.providerLeaseId) {
|
||||
return {
|
||||
providerLeaseId: null,
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
expired: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
const sandbox = await getSandboxOrNull(config, params.providerLeaseId);
|
||||
if (!sandbox) {
|
||||
return {
|
||||
providerLeaseId: null,
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
expired: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
await sandbox.setTimeout(config.timeoutMs, { requestTimeoutMs: config.requestTimeoutMs }).catch(() => undefined);
|
||||
const remoteCwd =
|
||||
typeof params.leaseMetadata?.remoteCwd === "string" && params.leaseMetadata.remoteCwd.trim().length > 0
|
||||
? params.leaseMetadata.remoteCwd.trim()
|
||||
: config.requestedCwd;
|
||||
await ensureWorkspace(sandbox, remoteCwd, config);
|
||||
const shellCommand = await detectShellCommand(sandbox, config);
|
||||
return {
|
||||
providerLeaseId: sandbox.sandboxId,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
shellCommand,
|
||||
remoteCwd,
|
||||
resumedLease: true,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
await releaseSandbox(config, params.providerLeaseId);
|
||||
},
|
||||
|
||||
async onEnvironmentDestroyLease(
|
||||
params: PluginEnvironmentDestroyLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
const sandbox = await getSandboxOrNull(config, params.providerLeaseId);
|
||||
await sandbox?.kill({ requestTimeoutMs: config.requestTimeoutMs }).catch(() => undefined);
|
||||
},
|
||||
|
||||
async onEnvironmentRealizeWorkspace(
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
const remoteCwd =
|
||||
typeof params.lease.metadata?.remoteCwd === "string" && params.lease.metadata.remoteCwd.trim().length > 0
|
||||
? params.lease.metadata.remoteCwd.trim()
|
||||
: params.workspace.remotePath ?? params.workspace.localPath ?? config.requestedCwd;
|
||||
|
||||
if (params.lease.providerLeaseId) {
|
||||
const sandbox = await getSandboxOrNull(config, params.lease.providerLeaseId);
|
||||
if (sandbox) {
|
||||
await ensureWorkspace(sandbox, remoteCwd, config);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: remoteCwd,
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
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.\n",
|
||||
};
|
||||
}
|
||||
const config = parseNovitaDriverConfig(params.config);
|
||||
const sandbox = await getSandboxOrNull(config, params.lease.providerLeaseId);
|
||||
if (!sandbox) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Novita sandbox lease is no longer available.\n",
|
||||
metadata: {
|
||||
provider: "novita",
|
||||
sandboxId: params.lease.providerLeaseId,
|
||||
expired: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return await executeInSandbox(sandbox, params, config);
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { runWorker } from "@paperclipai/plugin-sdk";
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, lstatSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -20,6 +20,12 @@ mkdirSync(scopeDir, { recursive: true });
|
||||
try {
|
||||
const stat = lstatSync(linkTarget);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const existingTarget = readlinkSync(linkTarget);
|
||||
const relativeSdkDir = relative(scopeDir, sdkDir);
|
||||
if (existingTarget === relativeSdkDir) {
|
||||
console.log(` ✓ Local @paperclipai/plugin-sdk already linked for ${packageDir}`);
|
||||
process.exit(0);
|
||||
}
|
||||
rmSync(linkTarget, { force: true });
|
||||
} else {
|
||||
console.log(" i Keeping existing installed @paperclipai/plugin-sdk directory in place");
|
||||
|
||||
@@ -134,6 +134,11 @@
|
||||
"name": "@paperclipai/plugin-kubernetes",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "packages/plugins/sandbox-providers/novita",
|
||||
"name": "@paperclipai/plugin-novita-sandbox",
|
||||
"publishFromCi": false
|
||||
},
|
||||
{
|
||||
"dir": "ui",
|
||||
"name": "@paperclipai/ui",
|
||||
|
||||
Reference in New Issue
Block a user