diff --git a/README.md b/README.md index ca238010..d794b6ce 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index d4036c3d..d6a5143b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/cli/README.md b/cli/README.md index 8fa213f4..2f2fd155 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 diff --git a/packages/plugins/sandbox-providers/novita/README.md b/packages/plugins/sandbox-providers/novita/README.md new file mode 100644 index 00000000..2026978d --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/README.md @@ -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/` diff --git a/packages/plugins/sandbox-providers/novita/package.json b/packages/plugins/sandbox-providers/novita/package.json new file mode 100644 index 00000000..34fa4f39 --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/package.json @@ -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" + } +} diff --git a/packages/plugins/sandbox-providers/novita/src/index.ts b/packages/plugins/sandbox-providers/novita/src/index.ts new file mode 100644 index 00000000..3ea8bc6e --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/src/index.ts @@ -0,0 +1,2 @@ +export { default } from "./plugin.js"; +export { default as manifest } from "./manifest.js"; diff --git a/packages/plugins/sandbox-providers/novita/src/manifest.ts b/packages/plugins/sandbox-providers/novita/src/manifest.ts new file mode 100644 index 00000000..9268e268 --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/src/manifest.ts @@ -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; diff --git a/packages/plugins/sandbox-providers/novita/src/plugin.test.ts b/packages/plugins/sandbox-providers/novita/src/plugin.test.ts new file mode 100644 index 00000000..29a3fe22 --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/src/plugin.test.ts @@ -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, + }, + }); + }); +}); diff --git a/packages/plugins/sandbox-providers/novita/src/plugin.ts b/packages/plugins/sandbox-providers/novita/src/plugin.ts new file mode 100644 index 00000000..aec7c75b --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/src/plugin.ts @@ -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): 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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (!params.providerLeaseId) return; + const config = parseNovitaDriverConfig(params.config); + await releaseSandbox(config, params.providerLeaseId); + }, + + async onEnvironmentDestroyLease( + params: PluginEnvironmentDestroyLeaseParams, + ): Promise { + 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 { + 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 { + 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; diff --git a/packages/plugins/sandbox-providers/novita/src/worker.ts b/packages/plugins/sandbox-providers/novita/src/worker.ts new file mode 100644 index 00000000..1e156024 --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/src/worker.ts @@ -0,0 +1,5 @@ +import { runWorker } from "@paperclipai/plugin-sdk"; +import plugin from "./plugin.js"; + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/sandbox-providers/novita/tsconfig.json b/packages/plugins/sandbox-providers/novita/tsconfig.json new file mode 100644 index 00000000..000e3293 --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/plugins/sandbox-providers/novita/vitest.config.ts b/packages/plugins/sandbox-providers/novita/vitest.config.ts new file mode 100644 index 00000000..ce36a742 --- /dev/null +++ b/packages/plugins/sandbox-providers/novita/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/scripts/link-plugin-dev-sdk.mjs b/scripts/link-plugin-dev-sdk.mjs index 3e436a79..e4b4f425 100644 --- a/scripts/link-plugin-dev-sdk.mjs +++ b/scripts/link-plugin-dev-sdk.mjs @@ -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"); diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index e960e94d..66e53e16 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -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",