mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip runs across the CLI, server, adapters, plugins, CI, and container images. > - These surfaces declared different Node.js versions from 20 through 24. > - A newer `@types/node` major can expose APIs that the supported runtime does not provide. > - Node.js 20 is no longer a suitable project baseline, and Node.js 24 is the current LTS line. > - This pull request sets Node.js 24.11.0 as one repository-wide baseline, adds a drift check, and gives users actionable startup guidance when their runtime is too old. > - The benefit is one clear runtime contract for development, release, installation, and published packages. ## Linked Issues or Issue Description Refs #2734 Refs #11727 Refs #739 ## What Changed - Require Node.js 24.11.0 or newer in all 42 package manifests and runtime checks. - Use Node.js 24 in GitHub Actions, Docker images, smoke images, sandbox setup, portable installs, and esbuild targets. - Align every direct `@types/node` declaration on `^24.0.0`. - Prevent Dependabot from opening major `@types/node` upgrades without a matching runtime decision. - Add `.nvmrc` and a CI policy check for Node version drift. - Update ACP version gates, tests, and user documentation for the new minimum. - Print a non-blocking warning on CLI and server startup when Node is unsupported, with remediation through a version manager or the documented downloaded `install.sh` workflow. - Deduplicate that warning when `paperclipai run` boots the CLI and server in the same process. ## Verification - `node scripts/check-node-version-policy.mjs` - `node --check scripts/check-node-version-policy.mjs` - `node --check cli/esbuild.config.mjs` - `node --check scripts/generate-npm-package-json.mjs` - `bash -n scripts/install.sh scripts/test-install-sh-docker.sh scripts/e2e-install-lifecycle.sh` - Parsed all 42 package manifests and confirmed `engines.node` is `>=24.11.0`. - `git diff --check` - `vitest run packages/adapter-utils/src/sandbox-install-command.test.ts` passed with 3 tests. - `vitest run cli/src/node-version.test.ts` passed with 4 tests. - Directly exercised the shared warning helper for unsupported-version messaging and same-process deduplication. - The focused exe.dev suite could not resolve the locally unbuilt plugin SDK from this isolated worktree. A full offline workspace install was also blocked because the package-manager signature verifier requires registry access. The full suite was not run locally; draft CI performs a clean install and evaluates the wider impact. ## Risks - This is a breaking runtime change for users, plugins, and deployments that still use Node.js 20 or 22. - Published workspace packages will now produce an engine warning or failure in strict package managers on older Node.js releases. - Node.js 24 can reveal dependency, native module, Playwright, or agent CLI compatibility issues in CI. - The bootstrap installer now installs Node.js 24 when the current runtime is older than 24.11.0. - The portable sandbox fallback is pinned to Node.js 24.11.0 and depends on that upstream tarball remaining available. - Unsupported runtimes continue booting after a warning, so a later incompatibility can still fail at its point of use. - The CLI and server share the warning policy through the published `@paperclipai/shared` package; packaging checks must keep that subpath export available. - This PR does not commit `pnpm-lock.yaml` because repository policy assigns lockfile generation to CI. > 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 based on GPT-5. The exact deployment ID and context window are not exposed in this session. Reasoning, repository tools, shell execution, and GitHub tools were 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 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] 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>
731 lines
23 KiB
TypeScript
731 lines
23 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import manifest from "./manifest.js";
|
|
|
|
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 non-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("-c");
|
|
const script = call.argv[2]!;
|
|
expect(script).toMatch(/\/etc\/profile/);
|
|
// The wrapper sources no `nvm.sh`; the sandbox image supplies node on PATH.
|
|
expect(script).not.toMatch(/nvm\.sh/);
|
|
expect(script).not.toMatch(/NVM_DIR/);
|
|
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.",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("modal manifest form defaults", () => {
|
|
const configSchema = (
|
|
manifest.environmentDrivers?.[0]?.configSchema as {
|
|
properties?: Record<string, { format?: string; default?: unknown }>;
|
|
}
|
|
);
|
|
const properties = configSchema.properties ?? {};
|
|
|
|
it("pre-fills the required app name and image so the form works out of the box", () => {
|
|
expect(properties.appName?.default).toBe("paperclip");
|
|
expect(properties.image?.default).toBe("node:24");
|
|
});
|
|
|
|
it("declares no default on secret-ref fields, which would be persisted as a company secret", () => {
|
|
for (const prop of Object.values(properties)) {
|
|
if (prop.format === "secret-ref") {
|
|
expect(prop.default).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
});
|