mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
feat(plugins): add Modal sandbox provider plugin (#6245)
## 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>
This commit is contained in:
parent
8f45d12447
commit
4b1e92a588
10 changed files with 1633 additions and 0 deletions
703
packages/plugins/sandbox-providers/modal/src/plugin.test.ts
Normal file
703
packages/plugins/sandbox-providers/modal/src/plugin.test.ts
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError } = vi.hoisted(() => {
|
||||
class MockNotFoundError extends Error {}
|
||||
class MockTimeoutError extends Error {}
|
||||
class MockSandboxTimeoutError extends Error {}
|
||||
return { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError };
|
||||
});
|
||||
|
||||
const mockAppFromName = vi.hoisted(() => vi.fn());
|
||||
const mockImageFromRegistry = vi.hoisted(() => vi.fn(() => ({ kind: "image" })));
|
||||
const mockSandboxesCreate = vi.hoisted(() => vi.fn());
|
||||
const mockSandboxesFromId = vi.hoisted(() => vi.fn());
|
||||
const mockClientClose = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("modal", () => ({
|
||||
ModalClient: class MockModalClient {
|
||||
apps = { fromName: mockAppFromName };
|
||||
images = { fromRegistry: mockImageFromRegistry };
|
||||
sandboxes = { create: mockSandboxesCreate, fromId: mockSandboxesFromId };
|
||||
close = mockClientClose;
|
||||
constructor(_params?: unknown) {}
|
||||
},
|
||||
NotFoundError: MockNotFoundError,
|
||||
TimeoutError: MockTimeoutError,
|
||||
SandboxTimeoutError: MockSandboxTimeoutError,
|
||||
}));
|
||||
|
||||
import plugin from "./plugin.js";
|
||||
|
||||
interface FakeSandboxOverrides {
|
||||
id?: string;
|
||||
execImpl?: (argv: string[], params?: unknown) => Promise<FakeProcess>;
|
||||
}
|
||||
|
||||
interface FakeProcess {
|
||||
stdout: { readText: () => Promise<string> };
|
||||
stderr: { readText: () => Promise<string> };
|
||||
wait: () => Promise<number>;
|
||||
}
|
||||
|
||||
function makeFakeProcess(input: {
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
throwOnWait?: unknown;
|
||||
}): FakeProcess {
|
||||
return {
|
||||
stdout: { readText: vi.fn().mockResolvedValue(input.stdout ?? "") },
|
||||
stderr: { readText: vi.fn().mockResolvedValue(input.stderr ?? "") },
|
||||
wait: vi.fn().mockImplementation(async () => {
|
||||
if (input.throwOnWait) throw input.throwOnWait;
|
||||
return input.exitCode ?? 0;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSandbox(overrides: FakeSandboxOverrides = {}) {
|
||||
const execCalls: Array<{ argv: string[]; params?: unknown }> = [];
|
||||
const defaultExec = async (_argv: string[], _params?: unknown): Promise<FakeProcess> =>
|
||||
makeFakeProcess({ exitCode: 0, stdout: "paperclip-probe" });
|
||||
const exec = vi.fn().mockImplementation(async (argv: string[], params?: unknown) => {
|
||||
execCalls.push({ argv, params });
|
||||
return overrides.execImpl ? overrides.execImpl(argv, params) : defaultExec(argv, params);
|
||||
});
|
||||
const openedFiles: Array<{ path: string; mode: string; written: Uint8Array | null }> = [];
|
||||
const sandbox = {
|
||||
sandboxId: overrides.id ?? "sb-123",
|
||||
exec,
|
||||
execCalls,
|
||||
openedFiles,
|
||||
setTags: vi.fn().mockResolvedValue(undefined),
|
||||
terminate: vi.fn().mockResolvedValue(undefined),
|
||||
detach: vi.fn(),
|
||||
poll: vi.fn().mockResolvedValue(null),
|
||||
open: vi.fn().mockImplementation(async (path: string, mode: string) => {
|
||||
const entry: { path: string; mode: string; written: Uint8Array | null } = {
|
||||
path,
|
||||
mode,
|
||||
written: null,
|
||||
};
|
||||
openedFiles.push(entry);
|
||||
return {
|
||||
write: vi.fn().mockImplementation(async (data: Uint8Array) => {
|
||||
entry.written = data;
|
||||
}),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
type FakeSandbox = ReturnType<typeof createFakeSandbox>;
|
||||
|
||||
const baseAcquireParams = {
|
||||
driverKey: "modal",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
};
|
||||
|
||||
const baseConfig = {
|
||||
appName: "paperclip-app",
|
||||
image: "node:20",
|
||||
sandboxTimeoutMs: 3_600_000,
|
||||
execTimeoutMs: 300_000,
|
||||
reuseLease: false,
|
||||
};
|
||||
|
||||
const baseConfigWithTokens = {
|
||||
...baseConfig,
|
||||
tokenId: "config-id",
|
||||
tokenSecret: "config-secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockAppFromName.mockReset();
|
||||
mockImageFromRegistry.mockReset();
|
||||
mockImageFromRegistry.mockReturnValue({ kind: "image" });
|
||||
mockSandboxesCreate.mockReset();
|
||||
mockSandboxesFromId.mockReset();
|
||||
mockClientClose.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.MODAL_TOKEN_ID;
|
||||
delete process.env.MODAL_TOKEN_SECRET;
|
||||
});
|
||||
|
||||
describe("Modal sandbox provider plugin", () => {
|
||||
it("declares environment lifecycle handlers", async () => {
|
||||
expect(await plugin.definition.onHealth?.()).toEqual({
|
||||
status: "ok",
|
||||
message: "Modal sandbox provider plugin healthy",
|
||||
});
|
||||
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentReleaseLease).toBeTypeOf("function");
|
||||
expect(plugin.definition.onEnvironmentResumeLease).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("normalizes config when both tokens are provided", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: {
|
||||
appName: " app-1 ",
|
||||
image: " node:20 ",
|
||||
tokenId: " token-id ",
|
||||
tokenSecret: " token-secret ",
|
||||
environment: " main ",
|
||||
workdir: " /srv/work ",
|
||||
sandboxTimeoutMs: "1800000",
|
||||
idleTimeoutMs: "60000",
|
||||
execTimeoutMs: "120000",
|
||||
reuseLease: true,
|
||||
blockNetwork: false,
|
||||
cidrAllowlist: ["10.0.0.0/8"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
normalizedConfig: {
|
||||
appName: "app-1",
|
||||
image: "node:20",
|
||||
tokenId: "token-id",
|
||||
tokenSecret: "token-secret",
|
||||
environment: "main",
|
||||
workdir: "/srv/work",
|
||||
sandboxTimeoutMs: 1_800_000,
|
||||
idleTimeoutMs: 60_000,
|
||||
execTimeoutMs: 120_000,
|
||||
blockNetwork: false,
|
||||
cidrAllowlist: ["10.0.0.0/8"],
|
||||
reuseLease: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores host MODAL_TOKEN_* env vars (plugin worker does not inherit them)", async () => {
|
||||
process.env.MODAL_TOKEN_ID = "host-id";
|
||||
process.env.MODAL_TOKEN_SECRET = "host-secret";
|
||||
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid config", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: {
|
||||
appName: "",
|
||||
image: "",
|
||||
sandboxTimeoutMs: 1500,
|
||||
idleTimeoutMs: 1500,
|
||||
execTimeoutMs: 0,
|
||||
blockNetwork: true,
|
||||
cidrAllowlist: ["1.2.3.4/32"],
|
||||
tokenId: "only-id",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [
|
||||
"Modal sandbox environments require an appName.",
|
||||
"Modal sandbox environments require an image reference.",
|
||||
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
|
||||
"idleTimeoutMs must be a positive multiple of 1000 when provided.",
|
||||
"execTimeoutMs must be a positive multiple of 1000.",
|
||||
"cidrAllowlist cannot be combined with blockNetwork.",
|
||||
"tokenId and tokenSecret must both be provided when either is set.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires both tokens in config", async () => {
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "modal",
|
||||
config: { ...baseConfig },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
|
||||
});
|
||||
});
|
||||
|
||||
it("probes by creating, executing, and terminating a sandbox", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: { ...baseConfig, workdir: "/srv/work" },
|
||||
});
|
||||
|
||||
expect(mockAppFromName).toHaveBeenCalledWith("paperclip-app", {
|
||||
createIfMissing: true,
|
||||
environment: undefined,
|
||||
});
|
||||
expect(mockImageFromRegistry).toHaveBeenCalledWith("node:20");
|
||||
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
|
||||
"paperclip-provider": "modal",
|
||||
"paperclip-company-id": "c-1",
|
||||
}));
|
||||
// First exec is the mkdir for the workspace, second is the probe command.
|
||||
expect(sandbox.execCalls[0]?.argv).toEqual([
|
||||
"sh",
|
||||
"-lc",
|
||||
"mkdir -p '/srv/work'",
|
||||
]);
|
||||
expect(sandbox.execCalls[1]?.argv).toEqual([
|
||||
"sh",
|
||||
"-lc",
|
||||
"printf paperclip-probe",
|
||||
]);
|
||||
expect(sandbox.terminate).toHaveBeenCalled();
|
||||
expect(mockClientClose).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
metadata: {
|
||||
provider: "modal",
|
||||
sandboxId: "sb-123",
|
||||
remoteCwd: "/srv/work",
|
||||
reuseLease: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a failure probe result when the probe command exits non-zero", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2] === "printf paperclip-probe") {
|
||||
return makeFakeProcess({ exitCode: 7, stdout: "boom" });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(result?.ok).toBe(false);
|
||||
expect(sandbox.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the Modal client when probe fails before sandbox creation", async () => {
|
||||
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
|
||||
|
||||
const result = await plugin.definition.onEnvironmentProbe?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
summary: "Modal sandbox probe failed.",
|
||||
metadata: expect.objectContaining({
|
||||
error: "app lookup failed",
|
||||
}),
|
||||
});
|
||||
expect(mockClientClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("acquires a lease, applies tags, and ensures the workspace directory", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-acquire" });
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: { ...baseConfig, reuseLease: true, workdir: "/srv/work" },
|
||||
});
|
||||
|
||||
expect(lease).toEqual({
|
||||
providerLeaseId: "sb-acquire",
|
||||
metadata: expect.objectContaining({
|
||||
provider: "modal",
|
||||
sandboxId: "sb-acquire",
|
||||
remoteCwd: "/srv/work",
|
||||
reuseLease: true,
|
||||
resumedLease: false,
|
||||
}),
|
||||
});
|
||||
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
|
||||
"paperclip-run-id": "run-1",
|
||||
"paperclip-reuse-lease": "true",
|
||||
}));
|
||||
expect(sandbox.execCalls[0]?.argv).toEqual(["sh", "-lc", "mkdir -p '/srv/work'"]);
|
||||
});
|
||||
|
||||
it("terminates the sandbox if acquire workspace setup throws", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2]?.startsWith("mkdir -p")) {
|
||||
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: baseConfig,
|
||||
}),
|
||||
).rejects.toThrow("mkdir failed");
|
||||
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails acquire when workspace creation exits non-zero", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2]?.startsWith("mkdir -p")) {
|
||||
return makeFakeProcess({ exitCode: 17 });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
|
||||
mockSandboxesCreate.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: baseConfig,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Failed to create remote workspace directory '/workspace/paperclip': mkdir exited with code 17",
|
||||
);
|
||||
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes the Modal client when acquire fails before sandbox creation", async () => {
|
||||
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentAcquireLease?.({
|
||||
...baseAcquireParams,
|
||||
config: baseConfig,
|
||||
}),
|
||||
).rejects.toThrow("app lookup failed");
|
||||
expect(mockClientClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats missing leases as expired on resume", async () => {
|
||||
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-missing",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
});
|
||||
expect(lease).toEqual({ providerLeaseId: null, metadata: { expired: true } });
|
||||
});
|
||||
|
||||
it("resumes a reusable lease by reconnecting via fromId", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-resume" });
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-resume",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
});
|
||||
|
||||
expect(lease).toEqual({
|
||||
providerLeaseId: "sb-resume",
|
||||
metadata: expect.objectContaining({
|
||||
provider: "modal",
|
||||
sandboxId: "sb-resume",
|
||||
resumedLease: true,
|
||||
reuseLease: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("detaches the sandbox if resumed workspace setup fails", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
id: "sb-resume",
|
||||
execImpl: async (argv: string[]) => {
|
||||
if (argv[2]?.startsWith("mkdir -p")) {
|
||||
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
|
||||
}
|
||||
return makeFakeProcess({ exitCode: 0 });
|
||||
},
|
||||
});
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentResumeLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-resume",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
}),
|
||||
).rejects.toThrow("mkdir failed");
|
||||
expect(sandbox.detach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("detaches reusable leases and terminates ephemeral leases on release", async () => {
|
||||
const reusable = createFakeSandbox({ id: "sb-reuse" });
|
||||
const ephemeral = createFakeSandbox({ id: "sb-ephem" });
|
||||
mockSandboxesFromId.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
|
||||
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-reuse",
|
||||
config: { ...baseConfig, reuseLease: true },
|
||||
});
|
||||
await plugin.definition.onEnvironmentReleaseLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-ephem",
|
||||
config: { ...baseConfig, reuseLease: false },
|
||||
});
|
||||
|
||||
expect(reusable.detach).toHaveBeenCalled();
|
||||
expect(reusable.terminate).not.toHaveBeenCalled();
|
||||
expect(ephemeral.terminate).toHaveBeenCalled();
|
||||
expect(ephemeral.detach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys leases by terminating, ignoring missing sandboxes", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-destroy" });
|
||||
mockSandboxesFromId.mockResolvedValueOnce(sandbox);
|
||||
|
||||
await plugin.definition.onEnvironmentDestroyLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-destroy",
|
||||
config: baseConfig,
|
||||
});
|
||||
expect(sandbox.terminate).toHaveBeenCalled();
|
||||
|
||||
mockSandboxesFromId.mockRejectedValueOnce(new MockNotFoundError("missing"));
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentDestroyLease?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
providerLeaseId: "sb-missing",
|
||||
config: baseConfig,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("realizes the workspace using the lease metadata cwd when available", async () => {
|
||||
const sandbox = createFakeSandbox({ id: "sb-real" });
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: {
|
||||
providerLeaseId: "sb-real",
|
||||
metadata: { remoteCwd: "/srv/from-metadata" },
|
||||
},
|
||||
workspace: { localPath: "/local", remotePath: "/remote" },
|
||||
});
|
||||
|
||||
expect(sandbox.execCalls[0]?.argv).toEqual([
|
||||
"sh",
|
||||
"-lc",
|
||||
"mkdir -p '/srv/from-metadata'",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
cwd: "/srv/from-metadata",
|
||||
metadata: { provider: "modal", remoteCwd: "/srv/from-metadata" },
|
||||
});
|
||||
});
|
||||
|
||||
it("executes commands with a login-shell wrapper that injects env after profile sourcing", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async (argv: string[]) =>
|
||||
makeFakeProcess({
|
||||
exitCode: 5,
|
||||
stdout: "stdout-output",
|
||||
stderr: "stderr-output",
|
||||
}),
|
||||
});
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
cwd: "/srv/work",
|
||||
env: { FOO: "bar" },
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
|
||||
expect(sandbox.execCalls).toHaveLength(1);
|
||||
const call = sandbox.execCalls[0]!;
|
||||
expect(call.argv[0]).toBe("sh");
|
||||
expect(call.argv[1]).toBe("-lc");
|
||||
const script = call.argv[2]!;
|
||||
expect(script).toMatch(/\/etc\/profile/);
|
||||
expect(script).toMatch(/cd '\/srv\/work'/);
|
||||
expect(script).toMatch(/&& exec env FOO='bar' 'printf' 'hello'$/);
|
||||
expect(call.params).toMatchObject({
|
||||
timeoutMs: 12_000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
exitCode: 5,
|
||||
timedOut: false,
|
||||
stdout: "stdout-output",
|
||||
stderr: "stderr-output",
|
||||
});
|
||||
});
|
||||
|
||||
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "cat",
|
||||
args: [],
|
||||
stdin: "input payload",
|
||||
cwd: "/srv/work",
|
||||
});
|
||||
|
||||
expect(sandbox.openedFiles).toHaveLength(1);
|
||||
expect(sandbox.openedFiles[0]?.path).toMatch(/^\/tmp\/paperclip-stdin-/);
|
||||
expect(sandbox.openedFiles[0]?.mode).toBe("w");
|
||||
expect(sandbox.openedFiles[0]?.written).not.toBeNull();
|
||||
expect(new TextDecoder().decode(sandbox.openedFiles[0]!.written!)).toBe("input payload");
|
||||
|
||||
// First exec is the user command; second is the rm cleanup.
|
||||
const userCall = sandbox.execCalls[0]!;
|
||||
expect(userCall.argv[2]).toMatch(/&& exec 'cat' < '\/tmp\/paperclip-stdin-/);
|
||||
const cleanupCall = sandbox.execCalls[1]!;
|
||||
expect(cleanupCall.argv[2]).toMatch(/^rm -f '\/tmp\/paperclip-stdin-/);
|
||||
expect(result?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects invalid shell env keys before execution", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(
|
||||
plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
env: { "BAD-KEY": "v" },
|
||||
}),
|
||||
).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
|
||||
expect(sandbox.execCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns an error result when execute is called for an expired sandbox lease", async () => {
|
||||
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-expired", metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Modal sandbox lease is no longer available.\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a timedOut result when Modal raises a TimeoutError during exec", async () => {
|
||||
const sandbox = createFakeSandbox({
|
||||
execImpl: async () =>
|
||||
makeFakeProcess({ throwOnWait: new MockTimeoutError("exec timed out") }),
|
||||
});
|
||||
mockSandboxesFromId.mockResolvedValue(sandbox);
|
||||
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: "sb-exec", metadata: {} },
|
||||
command: "sleep",
|
||||
args: ["60"],
|
||||
cwd: "/srv/work",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
stdout: "",
|
||||
stderr: "exec timed out\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an error result when execute is called without a provider lease id", async () => {
|
||||
const result = await plugin.definition.onEnvironmentExecute?.({
|
||||
driverKey: "modal",
|
||||
companyId: "c-1",
|
||||
environmentId: "e-1",
|
||||
config: baseConfig,
|
||||
lease: { providerLeaseId: null, metadata: {} },
|
||||
command: "printf",
|
||||
args: ["hello"],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "No provider lease ID available for execution.",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue