mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path > - Paperclip orchestrates AI agents through company-scoped control-plane workflows and extensible runtime integrations. > - Sandbox providers are part of that extension surface because they let agents execute isolated work without baking each provider into the core server. > - Modal already offers managed sandboxes with filesystem, process, timeout, and networking controls that map onto Paperclip's sandbox provider contract. > - The repo did not have a Modal provider plugin, so teams wanting Modal-backed sandboxes had no first-party integration path. > - This pull request adds a standalone `packages/plugins/sandbox-providers/modal` plugin that implements the provider contract, worker entrypoint, docs, and tests. > - The benefit is that Modal can now be installed as a provider plugin without expanding the core control-plane surface area. ## What Changed - Added a new `packages/plugins/sandbox-providers/modal` package with the plugin manifest, worker entrypoint, and exported plugin surface. - Implemented Modal-backed sandbox lifecycle support for creation, command execution, file operations, networking options, termination, and metadata translation. - Added focused Vitest coverage for config validation, env handling, lifecycle flows, networking behavior, and error mapping. - Documented installation, configuration, and usage requirements in the plugin README. - Removed misleading `MODAL_TOKEN_*` fallback behavior so authentication relies on supported Modal credentials only. ## Verification - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` - `cd packages/plugins/sandbox-providers/modal && pnpm test` ## Risks - Low to medium risk: this is isolated to a new plugin package, but runtime behavior still depends on live Modal account credentials and service-side sandbox semantics. - Modal's current docs target a newer Node baseline than the repo default, so the first live install should confirm credential loading and sandbox startup behavior in a real Modal workspace. - No UI or schema changes are included in this PR. > 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 Codex via Paperclip `codex_local` agent (GPT-5-class Codex coding model; exact backend model ID is not exposed by the runtime), with tool use, shell execution, and code-editing capabilities enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] 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 - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
660 lines
22 KiB
TypeScript
660 lines
22 KiB
TypeScript
import {
|
|
ModalClient,
|
|
NotFoundError,
|
|
SandboxTimeoutError,
|
|
TimeoutError,
|
|
type App,
|
|
type ContainerProcess,
|
|
type Sandbox,
|
|
type SandboxCreateParams,
|
|
} from "modal";
|
|
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";
|
|
|
|
const DEFAULT_WORKDIR = "/workspace/paperclip";
|
|
const DEFAULT_SANDBOX_TIMEOUT_MS = 3_600_000;
|
|
const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
|
|
const MAX_SANDBOX_TIMEOUT_MS = 86_400_000;
|
|
|
|
interface ModalDriverConfig {
|
|
appName: string;
|
|
image: string;
|
|
tokenId: string | null;
|
|
tokenSecret: string | null;
|
|
environment: string | null;
|
|
workdir: string;
|
|
sandboxTimeoutMs: number;
|
|
idleTimeoutMs: number | null;
|
|
execTimeoutMs: number;
|
|
blockNetwork: boolean;
|
|
cidrAllowlist: string[] | null;
|
|
reuseLease: boolean;
|
|
}
|
|
|
|
function parseOptionalString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function parseOptionalNumber(value: unknown): number | null {
|
|
if (value == null || value === "") return null;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function parseStringArray(value: unknown): string[] | null {
|
|
if (!Array.isArray(value)) return null;
|
|
const trimmed = value
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
export function parseDriverConfig(raw: Record<string, unknown>): ModalDriverConfig {
|
|
const sandboxTimeoutMsRaw = parseOptionalNumber(raw.sandboxTimeoutMs);
|
|
const execTimeoutMsRaw = parseOptionalNumber(raw.execTimeoutMs);
|
|
const idleTimeoutMsRaw = parseOptionalNumber(raw.idleTimeoutMs);
|
|
return {
|
|
appName: parseOptionalString(raw.appName) ?? "",
|
|
image: parseOptionalString(raw.image) ?? "",
|
|
tokenId: parseOptionalString(raw.tokenId),
|
|
tokenSecret: parseOptionalString(raw.tokenSecret),
|
|
environment: parseOptionalString(raw.environment),
|
|
workdir: parseOptionalString(raw.workdir) ?? DEFAULT_WORKDIR,
|
|
sandboxTimeoutMs:
|
|
sandboxTimeoutMsRaw != null ? Math.trunc(sandboxTimeoutMsRaw) : DEFAULT_SANDBOX_TIMEOUT_MS,
|
|
idleTimeoutMs: idleTimeoutMsRaw != null ? Math.trunc(idleTimeoutMsRaw) : null,
|
|
execTimeoutMs:
|
|
execTimeoutMsRaw != null ? Math.trunc(execTimeoutMsRaw) : DEFAULT_EXEC_TIMEOUT_MS,
|
|
blockNetwork: raw.blockNetwork === true,
|
|
cidrAllowlist: parseStringArray(raw.cidrAllowlist),
|
|
reuseLease: raw.reuseLease === true,
|
|
};
|
|
}
|
|
|
|
function isMultipleOf1000(value: number): boolean {
|
|
return value > 0 && value % 1000 === 0;
|
|
}
|
|
|
|
function resolveAuth(config: ModalDriverConfig): { tokenId: string; tokenSecret: string } | null {
|
|
// The plugin worker runs in a child process that does not inherit host env
|
|
// vars (see PluginWorkerManager.spawnProcess), so MODAL_TOKEN_ID /
|
|
// MODAL_TOKEN_SECRET cannot be read here. Credentials must come from the
|
|
// environment config, which Paperclip stores as company secrets.
|
|
const tokenId = config.tokenId ?? "";
|
|
const tokenSecret = config.tokenSecret ?? "";
|
|
if (!tokenId && !tokenSecret) return null;
|
|
if (!tokenId || !tokenSecret) {
|
|
throw new Error("Modal sandbox environments require both tokenId and tokenSecret to be configured.");
|
|
}
|
|
return { tokenId, tokenSecret };
|
|
}
|
|
|
|
function createModalClient(config: ModalDriverConfig): ModalClient {
|
|
const auth = resolveAuth(config);
|
|
const params: ConstructorParameters<typeof ModalClient>[0] = {};
|
|
if (auth) {
|
|
params.tokenId = auth.tokenId;
|
|
params.tokenSecret = auth.tokenSecret;
|
|
}
|
|
if (config.environment) {
|
|
params.environment = config.environment;
|
|
}
|
|
return new ModalClient(params);
|
|
}
|
|
|
|
async function resolveApp(client: ModalClient, config: ModalDriverConfig): Promise<App> {
|
|
return await client.apps.fromName(config.appName, {
|
|
createIfMissing: true,
|
|
environment: config.environment ?? undefined,
|
|
});
|
|
}
|
|
|
|
function buildSandboxCreateParams(input: {
|
|
config: ModalDriverConfig;
|
|
tags: Record<string, string>;
|
|
}): SandboxCreateParams {
|
|
const params: SandboxCreateParams = {
|
|
workdir: input.config.workdir,
|
|
timeoutMs: input.config.sandboxTimeoutMs,
|
|
blockNetwork: input.config.blockNetwork,
|
|
};
|
|
if (input.config.idleTimeoutMs != null) {
|
|
params.idleTimeoutMs = input.config.idleTimeoutMs;
|
|
}
|
|
if (input.config.cidrAllowlist && input.config.cidrAllowlist.length > 0) {
|
|
params.cidrAllowlist = input.config.cidrAllowlist;
|
|
}
|
|
// Modal sandboxes accept tag metadata via setTags after creation; the create
|
|
// RPC does not take tags directly. We pass them through input so the caller
|
|
// can apply them after `create` resolves.
|
|
void input.tags;
|
|
return params;
|
|
}
|
|
|
|
function buildSandboxTags(input: {
|
|
companyId: string;
|
|
environmentId: string;
|
|
runId?: string;
|
|
reuseLease: boolean;
|
|
}): Record<string, string> {
|
|
return {
|
|
"paperclip-provider": "modal",
|
|
"paperclip-company-id": input.companyId,
|
|
"paperclip-environment-id": input.environmentId,
|
|
"paperclip-reuse-lease": input.reuseLease ? "true" : "false",
|
|
...(input.runId ? { "paperclip-run-id": input.runId } : {}),
|
|
};
|
|
}
|
|
|
|
async function createSandboxFor(
|
|
client: ModalClient,
|
|
app: App,
|
|
config: ModalDriverConfig,
|
|
tags: Record<string, string>,
|
|
): Promise<Sandbox> {
|
|
const image = client.images.fromRegistry(config.image);
|
|
const params = buildSandboxCreateParams({ config, tags });
|
|
const sandbox = await client.sandboxes.create(app, image, params);
|
|
try {
|
|
await sandbox.setTags(tags);
|
|
} catch (error) {
|
|
// setTags is best-effort metadata; surface but do not block lease creation.
|
|
console.warn(`Failed to set tags on Modal sandbox ${sandbox.sandboxId}: ${formatErrorMessage(error)}`);
|
|
}
|
|
return sandbox;
|
|
}
|
|
|
|
function leaseMetadata(input: {
|
|
config: ModalDriverConfig;
|
|
sandbox: Sandbox;
|
|
remoteCwd: string;
|
|
resumedLease: boolean;
|
|
}) {
|
|
return {
|
|
provider: "modal",
|
|
shellCommand: "sh",
|
|
sandboxId: input.sandbox.sandboxId,
|
|
appName: input.config.appName,
|
|
image: input.config.image,
|
|
sandboxTimeoutMs: input.config.sandboxTimeoutMs,
|
|
idleTimeoutMs: input.config.idleTimeoutMs,
|
|
reuseLease: input.config.reuseLease,
|
|
remoteCwd: input.remoteCwd,
|
|
resumedLease: input.resumedLease,
|
|
};
|
|
}
|
|
|
|
function formatErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function isValidShellEnvKey(value: string): boolean {
|
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
}
|
|
|
|
// Modal's `sandbox.exec` takes an argv array and bypasses the shell entirely,
|
|
// so adapter probes that rely on PATH mutations from /etc/profile or ~/.bashrc
|
|
// do not work without an explicit login shell. Mirroring the Daytona / E2B
|
|
// providers, wrap the user command in a `sh -lc` script that sources common
|
|
// login profiles plus nvm before invoking it. Env is set after profile sourcing
|
|
// so caller env wins; stdin is staged to a temp file and shell-redirected so
|
|
// fast-failing commands do not race a streaming stdin writer.
|
|
function buildLoginShellScript(input: {
|
|
command: string;
|
|
args: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdinPath?: string;
|
|
}): string {
|
|
const env = input.env ?? {};
|
|
for (const key of Object.keys(env)) {
|
|
if (!isValidShellEnvKey(key)) {
|
|
throw new Error(`Invalid sandbox environment variable key: ${key}`);
|
|
}
|
|
}
|
|
const envArgs = Object.entries(env)
|
|
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
|
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
|
|
const redirected = input.stdinPath
|
|
? `${commandParts} < ${shellQuote(input.stdinPath)}`
|
|
: commandParts;
|
|
const finalLine = envArgs.length > 0 ? `exec env ${envArgs.join(" ")} ${redirected}` : `exec ${redirected}`;
|
|
const lines = [
|
|
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
|
|
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
|
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
|
|
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
|
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
|
|
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
|
|
];
|
|
if (input.cwd) {
|
|
lines.push(`cd ${shellQuote(input.cwd)}`);
|
|
}
|
|
lines.push(finalLine);
|
|
return lines.join(" && ");
|
|
}
|
|
|
|
async function ensureRemoteWorkspace(sandbox: Sandbox, remoteCwd: string): Promise<void> {
|
|
// Use a one-shot exec to mkdir -p; Modal does not expose a direct
|
|
// filesystem mkdir helper and creating a file via `open()` does not create
|
|
// intermediate directories.
|
|
const proc = await sandbox.exec(["sh", "-lc", `mkdir -p ${shellQuote(remoteCwd)}`]);
|
|
const exitCode = await proc.wait();
|
|
if (exitCode !== 0) {
|
|
throw new Error(
|
|
`Failed to create remote workspace directory '${remoteCwd}': mkdir exited with code ${exitCode}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function stageStdin(sandbox: Sandbox, stdin: string, remotePath: string): Promise<void> {
|
|
const file = await sandbox.open(remotePath, "w");
|
|
try {
|
|
await file.write(new TextEncoder().encode(stdin));
|
|
await file.flush();
|
|
} finally {
|
|
await file.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function deleteStdinPath(sandbox: Sandbox, remotePath: string): Promise<void> {
|
|
// Best-effort cleanup of the staged stdin file. We swallow errors because
|
|
// it is fine for the file to outlive the sandbox if it is going to be
|
|
// terminated, and a missing rm tool would otherwise mask the real result.
|
|
try {
|
|
const proc = await sandbox.exec(["sh", "-lc", `rm -f ${shellQuote(remotePath)}`]);
|
|
await proc.wait();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function readProcessStreams(
|
|
proc: ContainerProcess<string>,
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
proc.stdout.readText(),
|
|
proc.stderr.readText(),
|
|
proc.wait(),
|
|
]);
|
|
return { stdout, stderr, exitCode };
|
|
}
|
|
|
|
function isModalNotFound(error: unknown): boolean {
|
|
return error instanceof NotFoundError;
|
|
}
|
|
|
|
async function getSandboxOrNull(
|
|
client: ModalClient,
|
|
providerLeaseId: string,
|
|
): Promise<Sandbox | null> {
|
|
try {
|
|
return await client.sandboxes.fromId(providerLeaseId);
|
|
} catch (error) {
|
|
if (isModalNotFound(error)) return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function warnIfUnsupportedNode(logger: { warn: (msg: string) => void } | undefined): void {
|
|
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
|
if (Number.isFinite(major) && major < 22) {
|
|
const message = `Modal sandbox provider is running on Node ${process.versions.node}; Modal officially supports Node 22+. The plugin will attempt to operate but vendor support is not guaranteed below Node 22.`;
|
|
logger?.warn(message);
|
|
}
|
|
}
|
|
|
|
function leaseRemoteCwd(metadata: Record<string, unknown> | undefined, fallback: string): string {
|
|
if (metadata && typeof metadata.remoteCwd === "string" && metadata.remoteCwd.trim().length > 0) {
|
|
return metadata.remoteCwd.trim();
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
warnIfUnsupportedNode(ctx.logger);
|
|
ctx.logger.info("Modal sandbox provider plugin ready");
|
|
},
|
|
|
|
async onHealth() {
|
|
return { status: "ok", message: "Modal sandbox provider plugin healthy" };
|
|
},
|
|
|
|
async onEnvironmentValidateConfig(
|
|
params: PluginEnvironmentValidateConfigParams,
|
|
): Promise<PluginEnvironmentValidationResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
const errors: string[] = [];
|
|
|
|
if (!config.appName) {
|
|
errors.push("Modal sandbox environments require an appName.");
|
|
}
|
|
if (!config.image) {
|
|
errors.push("Modal sandbox environments require an image reference.");
|
|
}
|
|
if (
|
|
config.sandboxTimeoutMs < 1000 ||
|
|
config.sandboxTimeoutMs > MAX_SANDBOX_TIMEOUT_MS ||
|
|
!isMultipleOf1000(config.sandboxTimeoutMs)
|
|
) {
|
|
errors.push(
|
|
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
|
|
);
|
|
}
|
|
if (
|
|
config.idleTimeoutMs != null &&
|
|
(config.idleTimeoutMs < 1000 || !isMultipleOf1000(config.idleTimeoutMs))
|
|
) {
|
|
errors.push("idleTimeoutMs must be a positive multiple of 1000 when provided.");
|
|
}
|
|
if (config.execTimeoutMs < 1000 || !isMultipleOf1000(config.execTimeoutMs)) {
|
|
errors.push("execTimeoutMs must be a positive multiple of 1000.");
|
|
}
|
|
if (config.blockNetwork && config.cidrAllowlist && config.cidrAllowlist.length > 0) {
|
|
errors.push("cidrAllowlist cannot be combined with blockNetwork.");
|
|
}
|
|
const hasTokenId = Boolean(config.tokenId);
|
|
const hasTokenSecret = Boolean(config.tokenSecret);
|
|
if (hasTokenId !== hasTokenSecret) {
|
|
errors.push("tokenId and tokenSecret must both be provided when either is set.");
|
|
} else if (!hasTokenId) {
|
|
errors.push("Modal sandbox environments require tokenId and tokenSecret.");
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return { ok: false, errors };
|
|
}
|
|
return { ok: true, normalizedConfig: { ...config } };
|
|
},
|
|
|
|
async onEnvironmentProbe(
|
|
params: PluginEnvironmentProbeParams,
|
|
): Promise<PluginEnvironmentProbeResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
const tags = buildSandboxTags({
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
reuseLease: false,
|
|
});
|
|
const client = createModalClient(config);
|
|
try {
|
|
const app = await resolveApp(client, config);
|
|
const sandbox = await createSandboxFor(client, app, config, tags);
|
|
try {
|
|
await ensureRemoteWorkspace(sandbox, config.workdir);
|
|
const proc = await sandbox.exec(["sh", "-lc", "printf paperclip-probe"]);
|
|
const { stdout, exitCode } = await readProcessStreams(proc);
|
|
if (exitCode !== 0 || stdout.trim() !== "paperclip-probe") {
|
|
return {
|
|
ok: false,
|
|
summary: `Modal sandbox probe failed: exit ${exitCode}, stdout=${JSON.stringify(stdout)}`,
|
|
metadata: {
|
|
provider: "modal",
|
|
sandboxId: sandbox.sandboxId,
|
|
appName: config.appName,
|
|
image: config.image,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
summary: `Connected to Modal sandbox in app ${config.appName}.`,
|
|
metadata: {
|
|
provider: "modal",
|
|
sandboxId: sandbox.sandboxId,
|
|
appName: config.appName,
|
|
image: config.image,
|
|
workdir: config.workdir,
|
|
sandboxTimeoutMs: config.sandboxTimeoutMs,
|
|
idleTimeoutMs: config.idleTimeoutMs,
|
|
reuseLease: config.reuseLease,
|
|
remoteCwd: config.workdir,
|
|
},
|
|
};
|
|
} finally {
|
|
await sandbox.terminate().catch(() => undefined);
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
summary: "Modal sandbox probe failed.",
|
|
metadata: {
|
|
provider: "modal",
|
|
appName: config.appName,
|
|
image: config.image,
|
|
reuseLease: config.reuseLease,
|
|
error: formatErrorMessage(error),
|
|
},
|
|
};
|
|
} finally {
|
|
client.close();
|
|
}
|
|
},
|
|
|
|
async onEnvironmentAcquireLease(
|
|
params: PluginEnvironmentAcquireLeaseParams,
|
|
): Promise<PluginEnvironmentLease> {
|
|
const config = parseDriverConfig(params.config);
|
|
const client = createModalClient(config);
|
|
try {
|
|
const app = await resolveApp(client, config);
|
|
const tags = buildSandboxTags({
|
|
companyId: params.companyId,
|
|
environmentId: params.environmentId,
|
|
runId: params.runId,
|
|
reuseLease: config.reuseLease,
|
|
});
|
|
const sandbox = await createSandboxFor(client, app, config, tags);
|
|
try {
|
|
await ensureRemoteWorkspace(sandbox, config.workdir);
|
|
return {
|
|
providerLeaseId: sandbox.sandboxId,
|
|
metadata: leaseMetadata({
|
|
config,
|
|
sandbox,
|
|
remoteCwd: config.workdir,
|
|
resumedLease: false,
|
|
}),
|
|
};
|
|
} catch (error) {
|
|
await sandbox.terminate().catch(() => undefined);
|
|
throw error;
|
|
}
|
|
} finally {
|
|
// Keep the client open for the lease lifetime is unnecessary; subsequent
|
|
// calls construct their own client. Close the local handle to free
|
|
// grpc resources.
|
|
client.close();
|
|
}
|
|
},
|
|
|
|
async onEnvironmentResumeLease(
|
|
params: PluginEnvironmentResumeLeaseParams,
|
|
): Promise<PluginEnvironmentLease> {
|
|
const config = parseDriverConfig(params.config);
|
|
const client = createModalClient(config);
|
|
try {
|
|
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
|
|
if (!sandbox) {
|
|
return { providerLeaseId: null, metadata: { expired: true } };
|
|
}
|
|
try {
|
|
await ensureRemoteWorkspace(sandbox, config.workdir);
|
|
return {
|
|
providerLeaseId: sandbox.sandboxId,
|
|
metadata: leaseMetadata({ config, sandbox, remoteCwd: config.workdir, resumedLease: true }),
|
|
};
|
|
} catch (error) {
|
|
// If we just resumed and workspace setup blew up, treat as a lease
|
|
// failure rather than silently terminating the user's reusable
|
|
// sandbox. Detach so the sandbox is not killed for a transient setup
|
|
// error.
|
|
void sandbox.detach();
|
|
throw error;
|
|
}
|
|
} finally {
|
|
client.close();
|
|
}
|
|
},
|
|
|
|
async onEnvironmentReleaseLease(
|
|
params: PluginEnvironmentReleaseLeaseParams,
|
|
): Promise<void> {
|
|
if (!params.providerLeaseId) return;
|
|
const config = parseDriverConfig(params.config);
|
|
const client = createModalClient(config);
|
|
try {
|
|
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
|
|
if (!sandbox) return;
|
|
if (config.reuseLease) {
|
|
// Modal has no separate pause primitive. Detaching releases the local
|
|
// grpc connection but leaves the sandbox running on Modal until its
|
|
// configured sandboxTimeoutMs or idleTimeoutMs expires. The next
|
|
// acquire/resume reconnects via sandboxes.fromId(providerLeaseId).
|
|
void sandbox.detach();
|
|
return;
|
|
}
|
|
await sandbox.terminate();
|
|
} finally {
|
|
client.close();
|
|
}
|
|
},
|
|
|
|
async onEnvironmentDestroyLease(
|
|
params: PluginEnvironmentDestroyLeaseParams,
|
|
): Promise<void> {
|
|
if (!params.providerLeaseId) return;
|
|
const config = parseDriverConfig(params.config);
|
|
const client = createModalClient(config);
|
|
try {
|
|
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
|
|
if (!sandbox) return;
|
|
await sandbox.terminate();
|
|
} finally {
|
|
client.close();
|
|
}
|
|
},
|
|
|
|
async onEnvironmentRealizeWorkspace(
|
|
params: PluginEnvironmentRealizeWorkspaceParams,
|
|
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
|
const config = parseDriverConfig(params.config);
|
|
const fallback =
|
|
params.workspace.remotePath ??
|
|
params.workspace.localPath ??
|
|
config.workdir;
|
|
const remoteCwd = leaseRemoteCwd(params.lease.metadata, fallback);
|
|
if (params.lease.providerLeaseId) {
|
|
const client = createModalClient(config);
|
|
try {
|
|
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
|
|
if (sandbox) {
|
|
await ensureRemoteWorkspace(sandbox, remoteCwd);
|
|
}
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
return {
|
|
cwd: remoteCwd,
|
|
metadata: { provider: "modal", remoteCwd },
|
|
};
|
|
},
|
|
|
|
async onEnvironmentExecute(
|
|
params: PluginEnvironmentExecuteParams,
|
|
): Promise<PluginEnvironmentExecuteResult> {
|
|
if (!params.lease.providerLeaseId) {
|
|
return {
|
|
exitCode: 1,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "No provider lease ID available for execution.",
|
|
};
|
|
}
|
|
const config = parseDriverConfig(params.config);
|
|
const client = createModalClient(config);
|
|
const callerTimeoutMs =
|
|
params.timeoutMs != null && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
|
|
? Math.max(1000, Math.trunc(params.timeoutMs / 1000) * 1000)
|
|
: config.execTimeoutMs;
|
|
|
|
try {
|
|
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
|
|
if (!sandbox) {
|
|
return {
|
|
exitCode: 1,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "Modal sandbox lease is no longer available.\n",
|
|
};
|
|
}
|
|
const stdinPath = params.stdin != null
|
|
? `/tmp/paperclip-stdin-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
: null;
|
|
try {
|
|
if (stdinPath && params.stdin != null) {
|
|
await stageStdin(sandbox, params.stdin, stdinPath);
|
|
}
|
|
const script = buildLoginShellScript({
|
|
command: params.command,
|
|
args: params.args ?? [],
|
|
cwd: params.cwd ?? config.workdir,
|
|
env: params.env,
|
|
stdinPath: stdinPath ?? undefined,
|
|
});
|
|
const proc = await sandbox.exec(["sh", "-lc", script], {
|
|
timeoutMs: callerTimeoutMs,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const { stdout, stderr, exitCode } = await readProcessStreams(proc);
|
|
return {
|
|
exitCode,
|
|
timedOut: false,
|
|
stdout,
|
|
stderr,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof TimeoutError || error instanceof SandboxTimeoutError) {
|
|
return {
|
|
exitCode: null,
|
|
timedOut: true,
|
|
stdout: "",
|
|
stderr: `${formatErrorMessage(error)}\n`,
|
|
};
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (stdinPath) {
|
|
await deleteStdinPath(sandbox, stdinPath);
|
|
}
|
|
}
|
|
} finally {
|
|
client.close();
|
|
}
|
|
},
|
|
});
|
|
|
|
export default plugin;
|