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:
@@ -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);
|
||||
Reference in New Issue
Block a user