fix(build): enforce Node 24 across Paperclip (#11792)

## 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>
This commit is contained in:
Nicky Leach 2026-08-21 10:17:52 -07:00 committed by GitHub
parent 33eb68b3ae
commit 38d8f37172
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 492 additions and 163 deletions

View File

@ -14,6 +14,13 @@ updates:
open-pull-requests-limit: 20
labels:
- "dependencies"
ignore:
# @types/node describes the APIs available in the supported Node runtime.
# Runtime major upgrades are deliberate compatibility changes, so keep
# Dependabot on the current major until the runtime baseline moves too.
- dependency-name: "@types/node"
update-types:
- "version-update:semver-major"
- package-ecosystem: github-actions
directory: "/"

View File

@ -31,7 +31,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
node-version: '24'
- name: Generate commitperclip token
id: token

View File

@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
- name: Refresh lockfile for Docker build context
run: |
@ -257,7 +257,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
- name: Refresh lockfile for Docker build context
run: |

View File

@ -24,7 +24,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile

View File

@ -49,6 +49,9 @@ jobs:
- name: Validate Dockerfile deps stage
run: node ./scripts/check-docker-deps-stage.mjs
- name: Validate Node version policy
run: pnpm check:node-version
- name: Reject git push in adapter/runtime code
run: node ./scripts/check-no-git-push.mjs

View File

@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: pnpm
- name: Refresh pnpm lockfile

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
24

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.20
FROM node:lts-trixie-slim AS base
FROM node:24-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \

View File

@ -311,7 +311,7 @@ fi
bash install.sh
```
The installer ensures Node.js 20 or newer is available, installs a managed
The installer ensures Node.js 24.11 or newer is available, installs a managed
Paperclip CLI under `~/.paperclip/cli`, and starts interactive onboarding. It
can also install Paperclip as a background service on supported Linux and
macOS systems. The checksum detects transfer or publishing mistakes, but it is
@ -376,7 +376,7 @@ pnpm dev
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
> **Requirements:** Node.js 24.11+, pnpm 9.15+
<br/>

View File

@ -305,7 +305,7 @@ pnpm dev
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
> **Requirements:** Node.js 24.11+, pnpm 9.15+
<br/>

View File

@ -70,7 +70,7 @@ export default {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "node",
target: "node20",
target: "node24",
format: "esm",
outfile: "dist/index.js",
banner: { js: "#!/usr/bin/env node" },

View File

@ -59,8 +59,11 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"tsx": "^4.23.12",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -7,6 +7,7 @@ import {
type InstallStorePaths,
} from "../install-store.js";
import type { CheckResult } from "./index.js";
import { isSupportedNodeVersion, MINIMUM_NODE_VERSION } from "@paperclipai/shared/node-version";
function pathContains(directory: string): boolean {
const normalized = path.resolve(directory);
@ -33,14 +34,13 @@ function hasManagedArtifacts(paths: InstallStorePaths): boolean {
}
export function nodeRuntimeCheck(): CheckResult {
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
return major >= 20
return isSupportedNodeVersion(process.versions.node)
? { name: "Node.js runtime", status: "pass", message: `Node.js ${process.versions.node}` }
: {
name: "Node.js runtime",
status: "fail",
message: `Node.js ${process.versions.node} is unsupported`,
repairHint: "Install Node.js 20 or newer before installing or running Paperclip",
repairHint: `Install Node.js ${MINIMUM_NODE_VERSION} or newer before installing or running Paperclip`,
};
}

View File

@ -5,6 +5,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { isSupportedNodeVersion, MINIMUM_NODE_VERSION } from "@paperclipai/shared/node-version";
import {
addManagedPathBlock,
assertManagedShimWritable,
@ -84,9 +85,8 @@ export function resolveGitInstallWorkspacePackages(checkoutPath: string): Releas
}
function assertSupportedNodeVersion(): void {
const major = Number(process.versions.node.split(".")[0]);
if (!Number.isFinite(major) || major < 20) {
throw new Error(`Managed installs require Node.js 20 or newer (found ${process.version}).`);
if (!isSupportedNodeVersion(process.versions.node)) {
throw new Error(`Managed installs require Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version}).`);
}
}

View File

@ -1,4 +1,5 @@
import { Command } from "commander";
import { warnIfUnsupportedNodeVersion } from "@paperclipai/shared/node-version";
import { onboard } from "./commands/onboard.js";
import { doctor } from "./commands/doctor.js";
import { envCommand } from "./commands/env.js";
@ -250,6 +251,8 @@ auth
registerClientAuthCommands(auth);
async function main(): Promise<void> {
warnIfUnsupportedNodeVersion(process.versions.node, (message) => console.warn(message));
let failed = false;
try {
await program.parseAsync();

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
formatNodeVersionWarning,
isSupportedNodeVersion,
MINIMUM_NODE_VERSION,
NODE_VERSION_INSTALL_GUIDE_URL,
warnIfUnsupportedNodeVersion,
} from "@paperclipai/shared/node-version";
describe("isSupportedNodeVersion", () => {
it("accepts the Node 24 LTS floor and newer releases", () => {
expect(MINIMUM_NODE_VERSION).toBe("24.11.0");
expect(isSupportedNodeVersion("v24.11.0")).toBe(true);
expect(isSupportedNodeVersion("24.19.0")).toBe(true);
expect(isSupportedNodeVersion("v26.0.0")).toBe(true);
});
it("rejects pre-LTS Node 24 and older major releases", () => {
expect(isSupportedNodeVersion("v24.10.0")).toBe(false);
expect(isSupportedNodeVersion("v22.23.0")).toBe(false);
expect(isSupportedNodeVersion("unknown")).toBe(false);
});
it("provides non-blocking remediation text for unsupported runtimes", () => {
expect(formatNodeVersionWarning("v24.11.0")).toBeNull();
const warning = formatNodeVersionWarning("v22.23.0");
expect(warning).toContain("Node.js v22.23.0 is unsupported");
expect(warning).toContain("requires Node.js 24.11.0 or newer");
expect(warning).toContain(NODE_VERSION_INSTALL_GUIDE_URL);
expect(warning).toContain("piped install.sh form cannot upgrade");
expect(warning).toContain("Restart Paperclip after upgrading");
});
it("emits at most one warning when CLI and server boot in the same process", () => {
const warnings: string[] = [];
expect(
warnIfUnsupportedNodeVersion("22.23.0", (message) => warnings.push(message)),
).toBe(true);
expect(
warnIfUnsupportedNodeVersion("22.23.0", (message) => warnings.push(message)),
).toBe(false);
expect(warnings).toHaveLength(1);
});
});

View File

@ -12,7 +12,7 @@ Current implementation status:
## Prerequisites
- Node.js 20+
- Node.js 24.11+
- pnpm 9+
## Dependency Lockfile Policy

View File

@ -23,7 +23,7 @@ bash install.sh
The bootstrap script:
1. verifies that the platform is supported;
2. ensures Node.js 20 or newer is available;
2. ensures Node.js 24.11 or newer is available;
3. delegates installation to `paperclipai install`;
4. starts interactive onboarding when stdin and stdout are terminals.
@ -241,6 +241,12 @@ paperclipai service status
presence and drift, running state, configured port ownership, and the running
server version.
The CLI and server also print a non-blocking startup warning when Node.js is
below the supported minimum. Upgrade Node.js with a version manager or follow
the downloaded `install.sh` workflow under **Recommended Install**. Do not use
the piped form for this repair because it requires a supported Node.js runtime
before it starts.
## Uninstall
Remove the background service and managed CLI payloads:

View File

@ -1313,7 +1313,7 @@ Required UX behaviors:
## 15.1 Environment
- Node 20+
- Node 24.11+
- `DATABASE_URL` optional
- if unset, auto-use embedded PostgreSQL under `~/.paperclip/instances/default/db`

View File

@ -11,7 +11,7 @@ pause controls, and consistent audits instead of hidden daemon behavior.
## Prerequisites
- Node.js 22+ and `pnpm`.
- Node.js 24.11+ and `pnpm`.
- A local Paperclip checkout you can run from source. Local plugin installs read source from disk, so the running server must be able to see the path you give it.
## The five steps

View File

@ -1,6 +1,6 @@
FROM ubuntu:24.04
ARG NODE_MAJOR=20
ARG NODE_MAJOR=24
ARG PAPERCLIPAI_VERSION=latest
ARG HOST_UID=10001

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.6
ARG NODE_VERSION=22
ARG NODE_VERSION=24
ARG TARGETARCH
# ---------- Stage 1: build agent-shim ----------

View File

@ -4,7 +4,7 @@ Container images for running coding-agent harnesses in sandboxed environments (f
## Image Lineup
- **`agent-runtime-base`**: Foundation. Ubuntu 22.04 + Node 22 + git + tini + non-root user (uid 1000) + the agent shim.
- **`agent-runtime-base`**: Foundation. Ubuntu 22.04 + Node 24 + git + tini + non-root user (uid 1000) + the agent shim.
- **`agent-runtime-opencode`**: Extends base with `opencode-ai` globally installed.
- **`agent-runtime-pi`**: Extends base with `@mariozechner/pi-coding-agent`.
- **`agent-runtime-codex`**: Extends base with `@openai/codex`.
@ -16,7 +16,7 @@ Container images for running coding-agent harnesses in sandboxed environments (f
**OS & Runtime:**
- Ubuntu 22.04
- Node.js 22 (via NodeSource APT repo)
- Node.js 24 (via NodeSource APT repo)
- git
- tini (PID-1 init, ensures signal propagation)
- Non-root user `paperclip` (uid/gid 1000)

View File

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.6
ARG HERMES_VERSION=0.17.0
FROM node:22-bookworm-slim
FROM node:24-bookworm-slim
ARG HERMES_VERSION

View File

@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:24-alpine
WORKDIR /app
COPY server.mjs /app/server.mjs

View File

@ -1,4 +1,4 @@
FROM node:lts-trixie-slim
FROM node:24-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \

View File

@ -74,7 +74,7 @@ my-adapter/
"picocolors": "^1.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.0"
}
}

View File

@ -7,7 +7,7 @@ Run Paperclip locally with zero external dependencies.
## Prerequisites
- Node.js 20+
- Node.js 24.11+
- pnpm 9+
## Start Dev Server

View File

@ -316,7 +316,7 @@ This issue does not affect the Docker Sandbox approach.
### Node version mismatch in community template images
Some community-built sandbox templates (e.g. `olegselajev241/openclaw-dmr:latest`) ship Node 20, but OpenClaw requires Node >=22.12.0. Use our locally built `openclaw:local` image as the sandbox template instead, which includes Node 22.
Some community-built sandbox templates (e.g. `olegselajev241/openclaw-dmr:latest`) ship Node 20, but OpenClaw requires Node >=22.12.0. Use our locally built `openclaw:local` image as the sandbox template instead, which includes Node 24.
### Gateway takes ~15 seconds to respond after start

View File

@ -29,7 +29,7 @@ Paperclip is a monorepo with four main layers.
| Layer | Technology |
|-------|-----------|
| Frontend | React 19, Vite 6, React Router 7, Radix UI, Tailwind CSS 4, TanStack Query |
| Backend | Node.js 20+, Express.js 5, TypeScript |
| Backend | Node.js 24.11+, Express.js 5, TypeScript |
| Database | PostgreSQL 17 (or embedded PGlite), Drizzle ORM |
| Auth | Better Auth (sessions + API keys) |
| Adapters | Claude Code CLI, Codex CLI, shell process, HTTP webhook |

View File

@ -25,7 +25,7 @@ npx paperclipai run
## Local Development
For contributors working on Paperclip itself. Prerequisites: Node.js 20+ and pnpm 9+.
For contributors working on Paperclip itself. Prerequisites: Node.js 24.11+ and pnpm 9+.
Clone the repository, then:

View File

@ -40,6 +40,7 @@
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"check:token-gates": "node scripts/check-token-gates.mjs",
"check:node-version": "node scripts/check-node-version-policy.mjs",
"check:no-git-push": "node scripts/check-no-git-push.mjs",
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
"test:install-sh-docker": "./scripts/test-install-sh-docker.sh",
@ -76,7 +77,7 @@
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20"
"node": ">=24.11.0"
},
"packageManager": "pnpm@9.15.4",
"pnpm": {

View File

@ -47,7 +47,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -9,9 +9,11 @@ describe("buildSandboxNpmInstallCommand", () => {
expect(command).toContain("npm install -g --prefix \"$HOME/.local\" '@google/gemini-cli'");
});
it("bootstraps npm from a portable Node tarball when missing", () => {
it("bootstraps npm from a portable Node tarball when missing or unsupported", () => {
const command = buildSandboxNpmInstallCommand("@google/gemini-cli");
expect(command).toContain("if ! command -v npm >/dev/null 2>&1; then");
expect(command).toContain("if ! command -v npm >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1 ||");
expect(command).toContain("process.versions.node");
expect(command).toContain("v[0]>24||(v[0]===24&&v[1]>=11)");
expect(command).toContain("https://nodejs.org/dist/");
expect(command).toContain('export PATH="$HOME/.local/bin:$PATH"');
});

View File

@ -13,13 +13,16 @@ function shellSingleQuote(value: string): string {
// $HOME/.local/bin.
const ENSURE_NPM_PREAMBLE =
"PAPERCLIP_NPM_BOOTSTRAPPED=; " +
'if ! command -v npm >/dev/null 2>&1; then ' +
"if ! command -v npm >/dev/null 2>&1 || " +
"! command -v node >/dev/null 2>&1 || " +
"! node -e 'const v=process.versions.node.split(\".\").map(Number);" +
"process.exit(v[0]>24||(v[0]===24&&v[1]>=11)?0:1)' >/dev/null 2>&1; then " +
'NODE_ARCH="$(uname -m)"; ' +
'case "$NODE_ARCH" in ' +
"x86_64) NODE_ARCH=x64 ;; " +
"aarch64|arm64) NODE_ARCH=arm64 ;; " +
"esac; " +
'NODE_VERSION="v22.11.0"; ' +
'NODE_VERSION="v24.11.0"; ' +
'NODE_TARBALL="node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; ' +
'mkdir -p "$HOME/.local"; ' +
'curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/${NODE_TARBALL}" -o "/tmp/${NODE_TARBALL}" && ' +

View File

@ -59,7 +59,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -69,7 +69,7 @@ Operational fields:
Notes:
- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Claude permission flags. Both require Bubblewrap on the host and select the CLI engine in auto mode; engine="acp" is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly.
- The Claude ACP lane requires Node >=22.12.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Auto engine selection falls back to CLI when those prerequisites are unavailable; explicit engine="acp" fails loudly.
- The Claude ACP lane requires Node >=24.11.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Auto engine selection falls back to CLI when those prerequisites are unavailable; explicit engine="acp" fails loudly.
- For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View File

@ -259,9 +259,9 @@ describe("claude_local ACP lane", () => {
});
it("checks the Node version required by the Claude ACP runtime", () => {
setNodeVersion("v22.11.0");
setNodeVersion("v24.10.0");
expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(false);
setNodeVersion("v22.12.0");
setNodeVersion("v24.11.0");
expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(true);
});
@ -270,7 +270,7 @@ describe("claude_local ACP lane", () => {
const commandPath = path.join(root, "bin", "claude-agent-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.12.0");
setNodeVersion("v24.11.0");
expect(resolveClaudeExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
await expect(
@ -286,7 +286,7 @@ describe("claude_local ACP lane", () => {
}),
).resolves.toEqual({ engine: "cli", explicit: true });
setNodeVersion("v22.11.0");
setNodeVersion("v24.10.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: { agentCommand: commandPath },
@ -341,7 +341,7 @@ describe("claude_local ACP lane", () => {
});
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
setNodeVersion("v22.12.0");
setNodeVersion("v24.11.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: { agentCommand: "claude-agent-acp" },
@ -367,7 +367,7 @@ describe("claude_local ACP lane", () => {
});
it("falls back to the CLI lane for one-shot sandbox auto runs", async () => {
setNodeVersion("v22.12.0");
setNodeVersion("v24.11.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: {},
@ -386,7 +386,7 @@ describe("claude_local ACP lane", () => {
});
it("falls back to the CLI lane for non-sandbox remote auto runs", async () => {
setNodeVersion("v22.12.0");
setNodeVersion("v24.11.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: {},
@ -418,7 +418,7 @@ describe("claude_local ACP lane", () => {
const commandPath = path.join(root, "bin", "claude-agent-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.12.0");
setNodeVersion("v24.11.0");
const result = await testClaudeAcpEnvironment({
adapterType: "claude_local",
@ -833,7 +833,7 @@ describe("claude_local ACP lane", () => {
});
it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => {
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
await expect(
resolveClaudeExecutionEngineForRun({
config: { agentCommand: "claude-agent-acp" },

View File

@ -53,7 +53,7 @@ import { SANDBOX_INSTALL_COMMAND } from "../index.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
const MIN_ACP_NODE_VERSION = "22.12.0";
const MIN_ACP_NODE_VERSION = "24.11.0";
export type ClaudeExecutionEngine = "cli" | "acp";

View File

@ -58,7 +58,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -129,5 +129,5 @@ Notes:
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- Fast mode is supported on GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- Codex ACP is the preferred auto lane when Node >=22.13.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
- Codex ACP is the preferred auto lane when Node >=24.11.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
`;

View File

@ -276,7 +276,7 @@ describe("codex_local ACP lane", () => {
const commandPath = path.join(root, "bin", "codex-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
expect(resolveCodexExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
await expect(
@ -296,7 +296,7 @@ describe("codex_local ACP lane", () => {
explicit: true,
});
setNodeVersion("v22.12.0");
setNodeVersion("v24.10.0");
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: commandPath },
@ -375,7 +375,7 @@ describe("codex_local ACP lane", () => {
});
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
await expect(
resolveCodexExecutionEngineForRun({
config: { agentCommand: "codex-acp" },
@ -401,7 +401,7 @@ describe("codex_local ACP lane", () => {
});
it("falls back to the CLI lane for one-shot sandbox auto runs", async () => {
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
await expect(
resolveCodexExecutionEngineForRun({
config: {},
@ -420,7 +420,7 @@ describe("codex_local ACP lane", () => {
});
it("falls back to the CLI lane for non-sandbox remote auto runs", async () => {
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
await expect(
resolveCodexExecutionEngineForRun({
config: {},
@ -479,9 +479,9 @@ describe("codex_local ACP lane", () => {
});
it("checks the Node version required by the ACPX runtime", () => {
setNodeVersion("v22.12.0");
setNodeVersion("v24.10.0");
expect(nodeVersionMeetsCodexAcpMinimum()).toBe(false);
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
expect(nodeVersionMeetsCodexAcpMinimum()).toBe(true);
});
@ -490,7 +490,7 @@ describe("codex_local ACP lane", () => {
const commandPath = path.join(root, "bin", "codex-acp");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
const result = await testCodexAcpEnvironment({
adapterType: "codex_local",
@ -543,7 +543,7 @@ describe("codex_local ACP lane", () => {
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"OPENAI_API_KEY":"sk-shared"}', "utf8");
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
process.env.CODEX_HOME = sharedCodexHome;
delete process.env.OPENAI_API_KEY;
@ -591,7 +591,7 @@ describe("codex_local ACP lane", () => {
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
await fs.mkdir(sharedCodexHome, { recursive: true });
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
process.env.CODEX_HOME = sharedCodexHome;
delete process.env.OPENAI_API_KEY;
@ -632,7 +632,7 @@ describe("codex_local ACP lane", () => {
"codex-home",
);
await fs.mkdir(sharedCodexHome, { recursive: true });
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
process.env.CODEX_HOME = sharedCodexHome;
delete process.env.OPENAI_API_KEY;
@ -1136,7 +1136,7 @@ describe("codex_local ACP lane", () => {
});
it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => {
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
// Isolate the missing bidirectional runner as the sole fallback cause:
// provide a valid ACP command and Node version so the only difference from
// the runner-backed ACP case is the absent `runner`.

View File

@ -48,7 +48,7 @@ import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
const MIN_ACP_NODE_VERSION = "22.13.0";
const MIN_ACP_NODE_VERSION = "24.11.0";
export type CodexExecutionEngine = "cli" | "acp";

View File

@ -55,7 +55,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -55,7 +55,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -55,7 +55,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -68,7 +68,7 @@ Operational fields:
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Gemini ACP is the preferred auto lane when Node >=20 and the local Gemini CLI command is available. It runs Gemini CLI's native \`gemini --acp\` server through Paperclip's shared ACP engine, including selected skill links, Paperclip runtime prompt/env guidance, model config, and persistent ACP session state. Auto selection falls back to the CLI lane when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
- Gemini ACP is the preferred auto lane when Node >=24.11.0 and the local Gemini CLI command is available. It runs Gemini CLI's native \`gemini --acp\` server through Paperclip's shared ACP engine, including selected skill links, Paperclip runtime prompt/env guidance, model config, and persistent ACP session state. Auto selection falls back to the CLI lane when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
- Runs use --prompt for non-interactive execution, not stdin.
- The adapter sets a headless-safe terminal/browser environment for Gemini CLI child processes so unattended runs do not wait on browser auth or 256-color terminal prompts.
- Sessions resume with --resume when stored session cwd matches the current cwd.

View File

@ -244,9 +244,9 @@ describe("gemini_local ACP lane", () => {
});
it("checks the Node version required by the Gemini ACP runtime", () => {
setNodeVersion("v19.9.0");
setNodeVersion("v24.10.0");
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(false);
setNodeVersion("v20.0.0");
setNodeVersion("v24.11.0");
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(true);
});
@ -255,7 +255,7 @@ describe("gemini_local ACP lane", () => {
const commandPath = path.join(root, "bin", "gemini");
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
setNodeVersion("v20.0.0");
setNodeVersion("v24.11.0");
expect(resolveGeminiExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
await expect(
@ -275,7 +275,7 @@ describe("gemini_local ACP lane", () => {
explicit: true,
});
setNodeVersion("v19.9.0");
setNodeVersion("v24.10.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: { command: commandPath },
@ -295,7 +295,7 @@ describe("gemini_local ACP lane", () => {
});
it("falls back to the CLI lane for non-sandbox remote auto runs", async () => {
setNodeVersion("v20.0.0");
setNodeVersion("v24.11.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: { agentCommand: "gemini --acp" },
@ -323,7 +323,7 @@ describe("gemini_local ACP lane", () => {
});
it("falls back to the CLI lane for one-shot sandbox auto runs", async () => {
setNodeVersion("v20.0.0");
setNodeVersion("v24.11.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: {},
@ -342,7 +342,7 @@ describe("gemini_local ACP lane", () => {
});
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
setNodeVersion("v20.0.0");
setNodeVersion("v24.11.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: { agentCommand: "gemini --acp" },
@ -681,7 +681,7 @@ describe("gemini_local ACP lane", () => {
});
it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => {
setNodeVersion("v22.13.0");
setNodeVersion("v24.11.0");
await expect(
resolveGeminiExecutionEngineForRun({
config: { agentCommand: "gemini --acp" },
@ -706,7 +706,7 @@ describe("gemini_local ACP lane", () => {
await fs.writeFile(path.join(bin, "gemini"), "#!/usr/bin/env sh\n", "utf8");
process.env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? ""}`;
process.env.GEMINI_API_KEY = "test-key";
setNodeVersion("v20.0.0");
setNodeVersion("v24.11.0");
const result = await testGeminiAcpEnvironment({
adapterType: "gemini_local",

View File

@ -35,7 +35,7 @@ import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
const MIN_ACP_NODE_VERSION = "20.0.0";
const MIN_ACP_NODE_VERSION = "24.11.0";
export type GeminiExecutionEngine = "cli" | "acp";

View File

@ -54,7 +54,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -71,11 +71,11 @@
"@paperclipai/hermes-paperclip-adapter": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20.0.0"
"node": ">=24.11.0"
}
}

View File

@ -103,11 +103,11 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20.0.0"
"node": ">=24.11.0"
}
}

View File

@ -55,8 +55,11 @@
"ws": "^8.21.3"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/ws": "^8.18.1",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -55,7 +55,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -54,7 +54,10 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -53,10 +53,13 @@
"postgres": "^3.4.9"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"drizzle-kit": "^0.31.10",
"tsx": "^4.23.12",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -50,8 +50,11 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -40,7 +40,7 @@ Or run the source directly during development:
```sh
cd packages/kv-demo-mcp-server
node --experimental-strip-types src/main.ts # Node 22+/24
node --experimental-strip-types src/main.ts # Node 24+
```
By default it listens on `http://127.0.0.1:8848` and prints three URLs to

View File

@ -48,8 +48,11 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -48,8 +48,11 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -45,7 +45,10 @@
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -196,7 +196,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
"@paperclipai/plugin-sdk": sdkDependency,
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"@types/react": "^19.0.8",
esbuild: "^0.27.3",
rollup: "^4.38.0",

View File

@ -31,7 +31,7 @@
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"esbuild": "^0.28.1",
"rollup": "^4.62.4",
@ -41,5 +41,8 @@
},
"peerDependencies": {
"react": ">=18"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -28,7 +28,7 @@
"codemirror": "^6.0.1"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"esbuild": "^0.28.1",
@ -38,5 +38,8 @@
},
"peerDependencies": {
"react": ">=18"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -22,7 +22,7 @@
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"react": "^19.2.8",
@ -31,5 +31,8 @@
},
"peerDependencies": {
"react": ">=18"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -24,7 +24,7 @@
},
"devDependencies": {
"esbuild": "^0.28.1",
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"react": "^19.2.8",
@ -33,5 +33,8 @@
},
"peerDependencies": {
"react": ">=18"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -32,7 +32,7 @@
"@paperclipai/shared": "workspace:*",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"esbuild": "^0.28.1",
"rollup": "^4.62.4",
@ -42,5 +42,8 @@
},
"peerDependencies": {
"react": ">=18"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -22,8 +22,11 @@
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -39,7 +39,7 @@
"@paperclipai/plugin-sdk": "workspace:*",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"esbuild": "^0.28.1",
@ -51,5 +51,8 @@
},
"peerDependencies": {
"react": ">=18"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -55,7 +55,7 @@
"@pierre/diffs": "^1.2.11"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"esbuild": "^0.28.1",
@ -67,5 +67,8 @@
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -17,5 +17,8 @@
"typescript": "^5.7.3",
"vitest": "^4.1.8",
"wrangler": "^4.15.0"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -51,8 +51,11 @@
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -53,8 +53,11 @@
"@daytonaio/sdk": "0.203.0"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -53,8 +53,11 @@
"e2b": "^2.19.0"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -37,7 +37,7 @@ Operational notes:
- Reusable leases keep the VM alive between runs. exe.dev does not expose a documented "stop and later resume" command in the public CLI docs, so `reuseLease: true` means "retain the VM" rather than "suspend it."
- The provisioning path uses `https://exe.dev/exec`, which exe.dev documents as a command-style HTTPS API with a 30-second request timeout. Typical `new` calls are expected to fit inside that limit; command execution itself does not use `/exec`.
- Probes still create and delete a real exe.dev VM through `/exec`, and so do the `new`/`rm` calls inside the normal acquire/release lifecycle. Treat all of those as real provisioning cost, not just probes.
- exe.dev runs `--setup-script` as the unprivileged `exedev` user, not as root. That user has passwordless `sudo`, so any system-level steps in a custom `setupScript` must invoke `sudo` explicitly (for example `sudo apt-get install -y …`). When you omit `setupScript`, the plugin supplies a default that installs Node 20 via the official nodesource script — Paperclip's sandbox callback bridge is a Node program, so the VM needs `node` on `PATH` before the bridge can launch.
- exe.dev runs `--setup-script` as the unprivileged `exedev` user, not as root. That user has passwordless `sudo`, so any system-level steps in a custom `setupScript` must invoke `sudo` explicitly (for example `sudo apt-get install -y …`). When you omit `setupScript`, the plugin supplies a default that installs Node 24 via the official nodesource script — Paperclip's sandbox callback bridge is a Node program, so the VM needs `node` on `PATH` before the bridge can launch.
## Local development

View File

@ -50,8 +50,11 @@
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -372,7 +372,9 @@ describe("exe.dev sandbox provider plugin", () => {
const body = String(fetchMock.mock.calls[0]?.[1]?.body ?? "");
expect(body).toContain("--setup-script=");
expect(body).toContain("nodesource.com/setup_20.x");
expect(body).toContain("process.versions.node");
expect(body).toContain("v[0]>24||(v[0]===24&&v[1]>=11)");
expect(body).toContain("nodesource.com/setup_24.x");
expect(body).toContain("sudo apt-get install -y nodejs");
});
@ -425,7 +427,7 @@ describe("exe.dev sandbox provider plugin", () => {
await acquirePromise?.catch((error: Error) => {
// Operator did not supply a setupScript, so the visible default install
// is not a secret and stays in the error for debuggability.
expect(error.message).toContain("nodesource.com/setup_20.x");
expect(error.message).toContain("nodesource.com/setup_24.x");
expect(error.message).not.toContain("[REDACTED]");
});
});

View File

@ -74,11 +74,13 @@ const UUID_SECRET_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-
// exe.dev's `--setup-script` runs at VM init as the unprivileged `exedev` user, which
// has passwordless sudo. The Paperclip sandbox callback bridge is a Node script, so
// every Paperclip workload on this provider needs node on PATH before the bridge can
// start. When the operator hasn't supplied their own setup script, install Node 20 via
// start. When the operator hasn't supplied their own setup script, install Node 24 via
// nodesource so the VM comes up ready for Paperclip out of the box.
const DEFAULT_SETUP_SCRIPT =
"command -v node >/dev/null 2>&1 || " +
"(curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && " +
"(command -v node >/dev/null 2>&1 && " +
"node -e 'const v=process.versions.node.split(\".\").map(Number);" +
"process.exit(v[0]>24||(v[0]===24&&v[1]>=11)?0:1)' >/dev/null 2>&1) || " +
"(curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && " +
"sudo apt-get install -y nodejs)";
class ExeDevApiError extends Error {

View File

@ -52,8 +52,11 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -16,9 +16,7 @@ The host plugin installer runs `npm install` into the managed plugin directory,
## Runtime support note
Modal's official JS SDK README pins support to **Node 22 or later**. Paperclip's repo baseline is currently `node >= 20`; empirically `modal@0.7.4` imports and operates against the Modal API under Node 20, so the plugin runs there today, but the vendor support contract is Node 22+. The plugin logs a startup warning when it detects Node `< 22`. Operators who can pin their Paperclip runtime to Node 22+ should do so; treat Node-20 usage as best-effort until the host bumps its baseline.
The empirical Node 20 compatibility check is recorded in [PAPA-352](/PAPA/issues/PAPA-352).
Paperclip and this plugin require **Node 24 or later**. The plugin logs a startup warning when it detects an older host runtime.
## Configuration
@ -27,7 +25,7 @@ Configure Modal from `Instance Settings -> Environments`, not from the plugin's
| Field | Required | Description |
| --- | --- | --- |
| `appName` | yes | Modal App name. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist. |
| `image` | yes | Container image passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`. |
| `image` | yes | Container image passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:24`. |
| `tokenId` / `tokenSecret` | yes | Modal auth tokens. Both must be provided together. Paperclip stores pasted values as company secrets. The plugin worker runs in a child process that does not inherit host env vars, so `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` set on the Paperclip server are **not** read by the plugin — provide the tokens in this form. |
| `environment` | no | Optional Modal environment name. Falls back to the SDK profile default. |
| `workdir` | no | Remote working directory inside the sandbox. Defaults to `/workspace/paperclip`. |

View File

@ -53,8 +53,11 @@
"modal": "^0.7.4"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -36,8 +36,8 @@ const manifest: PaperclipPluginManifestV1 = {
image: {
type: "string",
description:
"Container image reference passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:22`. The default `node:22` satisfies the sandbox runtime contract (node, sh, and tar on PATH).",
default: "node:22",
"Container image reference passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:24`. The default `node:24` satisfies the sandbox runtime contract (node, sh, and tar on PATH).",
default: "node:24",
},
tokenId: {
type: "string",

View File

@ -717,7 +717,7 @@ describe("modal manifest form defaults", () => {
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:22");
expect(properties.image?.default).toBe("node:24");
});
it("declares no default on secret-ref fields, which would be persisted as a company secret", () => {

View File

@ -318,8 +318,8 @@ async function getSandboxOrNull(
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.`;
if (Number.isFinite(major) && major < 24) {
const message = `Modal sandbox provider is running on Node ${process.versions.node}; Paperclip requires Node 24+. Upgrade the host runtime before using this plugin.`;
logger?.warn(message);
}
}

View File

@ -54,8 +54,11 @@
"novita-sandbox": "^1.0.3-b2"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -112,7 +112,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"typescript": "^5.7.3"
},
@ -123,5 +123,8 @@
"react": {
"optional": true
}
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -80,7 +80,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
bundle: true,
format: "esm",
platform: "node",
target: "node20",
target: "node24",
sourcemap,
minify,
external: ["react", "react-dom"],
@ -92,7 +92,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
bundle: true,
format: "esm",
platform: "node",
target: "node20",
target: "node24",
sourcemap,
external: ["@paperclipai/plugin-sdk"],
};

View File

@ -48,7 +48,10 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -0,0 +1,49 @@
export const MINIMUM_NODE_VERSION = "24.11.0";
export const NODE_VERSION_INSTALL_GUIDE_URL =
"https://github.com/paperclipai/paperclip/blob/master/doc/INSTALLING.md#recommended-install";
const NODE_VERSION_WARNING_EMITTED = Symbol.for("@paperclipai/node-version-warning-emitted");
function parseVersion(version: string): [number, number, number] | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function isSupportedNodeVersion(version: string): boolean {
const current = parseVersion(version);
const minimum = parseVersion(MINIMUM_NODE_VERSION);
if (!current || !minimum) return false;
for (let index = 0; index < current.length; index += 1) {
if (current[index] > minimum[index]) return true;
if (current[index] < minimum[index]) return false;
}
return true;
}
export function formatNodeVersionWarning(version: string): string | null {
if (isSupportedNodeVersion(version)) return null;
const currentVersion = version.trim() || "unknown";
return [
`[paperclip] warning: Node.js ${currentVersion} is unsupported. Paperclip requires Node.js ${MINIMUM_NODE_VERSION} or newer.`,
"Upgrade Node.js with your version manager, or follow the recommended downloaded install.sh workflow:",
` ${NODE_VERSION_INSTALL_GUIDE_URL}`,
"The piped install.sh form cannot upgrade an unsupported Node.js runtime.",
"Restart Paperclip after upgrading.",
].join("\n");
}
export function warnIfUnsupportedNodeVersion(
version: string,
warn: (message: string) => void,
): boolean {
const warning = formatNodeVersionWarning(version);
if (!warning) return false;
const warningState = globalThis as unknown as Record<symbol, boolean | undefined>;
if (warningState[NODE_VERSION_WARNING_EMITTED]) return false;
warningState[NODE_VERSION_WARNING_EMITTED] = true;
warn(warning);
return true;
}

View File

@ -52,6 +52,9 @@
"@paperclipai/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.20.1"
"@types/node": "^24.0.0"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -18,8 +18,11 @@
"test": "node scripts/build-native.mjs && vitest run"
},
"devDependencies": {
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -47,6 +47,9 @@
"validate": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/validate-catalog.ts"
},
"devDependencies": {
"@types/node": "^22.20.1"
"@types/node": "^24.0.0"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -0,0 +1,85 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const expectedEngine = ">=24.11.0";
const expectedTypes = "^24.0.0";
const failures = [];
const skippedDirectories = new Set([".git", ".paperclip", "coverage", "data", "dist", "node_modules"]);
function relative(filePath) {
return path.relative(repoRoot, filePath) || ".";
}
function walk(directory, visit) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && skippedDirectories.has(entry.name)) continue;
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) walk(entryPath, visit);
else if (entry.isFile()) visit(entryPath);
}
}
walk(repoRoot, (filePath) => {
if (path.basename(filePath) !== "package.json") return;
const manifest = JSON.parse(fs.readFileSync(filePath, "utf8"));
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
const specifier = manifest[section]?.["@types/node"];
if (specifier && specifier !== expectedTypes) {
failures.push(`${relative(filePath)}: ${section}.@types/node must be ${expectedTypes}, found ${specifier}`);
}
}
if (manifest.engines?.node !== expectedEngine) {
failures.push(`${relative(filePath)}: engines.node must be ${expectedEngine}, found ${manifest.engines?.node ?? "missing"}`);
}
});
const workflowRoot = path.join(repoRoot, ".github", "workflows");
walk(workflowRoot, (filePath) => {
if (!/\.ya?ml$/.test(filePath)) return;
const source = fs.readFileSync(filePath, "utf8");
for (const match of source.matchAll(/node-version:\s*["']?([^\s"'#]+)/g)) {
if (match[1] !== "24") failures.push(`${relative(filePath)}: node-version must be 24, found ${match[1]}`);
}
});
walk(repoRoot, (filePath) => {
if (!path.basename(filePath).startsWith("Dockerfile")) return;
const source = fs.readFileSync(filePath, "utf8");
for (const match of source.matchAll(/^\s*FROM\s+node:([^\s]+)/gm)) {
if (!match[1].startsWith("24-")) failures.push(`${relative(filePath)}: Node base image must use the Node 24 major, found node:${match[1]}`);
}
for (const match of source.matchAll(/^\s*ARG\s+NODE_(?:MAJOR|VERSION)=([^\s]+)/gm)) {
if (match[1] !== "24") failures.push(`${relative(filePath)}: Node build argument must be 24, found ${match[1]}`);
}
});
const requiredSourceFragments = [
[".nvmrc", "24"],
["packages/shared/src/node-version.ts", 'MINIMUM_NODE_VERSION = "24.11.0"'],
["scripts/install.sh", "MIN_NODE_VERSION=\"${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}.${MIN_NODE_PATCH}\""],
["scripts/install.sh", "MIN_NODE_MAJOR=24"],
["scripts/install.sh", "MIN_NODE_MINOR=11"],
["scripts/install.sh", "DEFAULT_NODE_MAJOR=24"],
["docker/agent-runtime/Dockerfile.base", "ARG NODE_VERSION=24"],
["packages/adapter-utils/src/sandbox-install-command.ts", "NODE_VERSION=\"v24.11.0\""],
["packages/plugins/sandbox-providers/exe-dev/src/plugin.ts", "nodesource.com/setup_24.x"],
["packages/plugins/sandbox-providers/modal/src/manifest.ts", 'default: "node:24"'],
["cli/esbuild.config.mjs", 'target: "node24"'],
["packages/plugins/sdk/src/bundlers.ts", 'target: "node24"'],
["scripts/generate-npm-package-json.mjs", `engines: { node: "${expectedEngine}" }`],
];
for (const [filePath, fragment] of requiredSourceFragments) {
const source = fs.readFileSync(path.join(repoRoot, filePath), "utf8");
if (!source.includes(fragment)) failures.push(`${filePath}: missing Node 24 policy fragment ${JSON.stringify(fragment)}`);
}
if (failures.length > 0) {
console.error("Node version policy check failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("Node version policy check passed (Node >=24.11.0, @types/node 24.x).");

View File

@ -6,7 +6,7 @@
# -> update --check -> update --rollback -> reinstall (payload reuse)
# -> bad-ref failure hygiene -> service lifecycle -> uninstall (data preserved)
#
# Machine requirements: bash, curl, tar, node >= 20 (with corepack), npm.
# Machine requirements: bash, curl, tar, node >= 24.11 (with corepack), npm.
# The machine's $HOME must not already contain a managed install.
#
# Env knobs:

View File

@ -110,7 +110,7 @@ const publishPkg = {
homepage: cliPkg.homepage,
bugs: cliPkg.bugs,
files: cliPkg.files,
engines: { node: ">=20" },
engines: { node: ">=24.11.0" },
dependencies: sortedDeps,
};

View File

@ -1,15 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
MIN_NODE_MAJOR=20
DEFAULT_NODE_MAJOR=22
MIN_NODE_MAJOR=24
MIN_NODE_MINOR=11
MIN_NODE_PATCH=0
MIN_NODE_VERSION="${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}.${MIN_NODE_PATCH}"
DEFAULT_NODE_MAJOR=24
PAPERCLIP_PACKAGE="paperclipai"
PUBLIC_NPM_REGISTRY="https://registry.npmjs.org"
HOMEBREW_INSTALL_COMMIT="99e13e96cbbdc1ac1ac09c0a40b450bf219ef3aa"
HOMEBREW_INSTALL_SHA256="99287f194a8b3c9e6b0203a11a5fa54518be57209343e6bb954dec4635796d9d"
NODESOURCE_DISTRIBUTIONS_COMMIT="9b431d8ae0f10df272598585855c6eca6c0e1bd2"
NODESOURCE_DEB_SHA256="575583bbac2fccc0b5edd0dbc03e222d9f9dc8d724da996d22754d6411104fd1"
NODESOURCE_RPM_SHA256="b0ed2b9b66002e7ee802e8777cf3a92b25f1ecc0129812dc6f59a43a536810cc"
NODESOURCE_DEB_SHA256="6e3d580f5bd7ccf2aa1e8df8d35c60d78e873c3ff8beb282c9bebd914904ad72"
NODESOURCE_RPM_SHA256="5550ad302050f887377a0451e720b800466573ebc83392fea80924393dba642b"
CANARY=0
VERSION=""
@ -190,19 +193,19 @@ esac
log "Detected $OS_NAME/$ARCH_NAME"
node_major() {
local version
has_supported_node() {
local version major minor patch
command -v node >/dev/null 2>&1 || return 1
version="$(node --version 2>/dev/null || true)"
version="${version#v}"
printf '%s' "${version%%.*}"
}
has_supported_node() {
local major
command -v node >/dev/null 2>&1 || return 1
major="$(node_major)"
[[ "$major" =~ ^[0-9]+$ ]] || return 1
[ "$major" -ge "$MIN_NODE_MAJOR" ] || return 1
IFS=. read -r major minor patch <<< "$version"
patch="${patch%%-*}"
[[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ && "$patch" =~ ^[0-9]+$ ]] || return 1
if [ "$major" -lt "$MIN_NODE_MAJOR" ] ||
{ [ "$major" -eq "$MIN_NODE_MAJOR" ] && [ "$minor" -lt "$MIN_NODE_MINOR" ]; } ||
{ [ "$major" -eq "$MIN_NODE_MAJOR" ] && [ "$minor" -eq "$MIN_NODE_MINOR" ] && [ "$patch" -lt "$MIN_NODE_PATCH" ]; }; then
return 1
fi
command -v npm >/dev/null 2>&1 || return 1
command -v npx >/dev/null 2>&1 || return 1
}
@ -339,7 +342,7 @@ if has_supported_node; then
log "Using Node.js $(node --version)"
else
if command -v node >/dev/null 2>&1; then
log "Node.js $(node --version 2>/dev/null || printf unknown) is too old; Node.js >= $MIN_NODE_MAJOR is required"
log "Node.js $(node --version 2>/dev/null || printf unknown) is too old; Node.js >= $MIN_NODE_VERSION is required"
else
log "Node.js was not found"
fi
@ -353,7 +356,7 @@ else
else
install_node_linux
fi
has_supported_node || fail "Node.js installation finished, but Node.js >= $MIN_NODE_MAJOR with npm/npx is not available"
has_supported_node || fail "Node.js installation finished, but Node.js >= $MIN_NODE_VERSION with npm/npx is not available"
log "Installed Node.js $(node --version)"
fi

View File

@ -36,7 +36,7 @@ run_with_node() {
-v "$RESULTS_DIR:/results" \
-e "PAPERCLIP_INSTALL_TEST_LOG=/results/$name.args" \
-e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
node:22-bookworm-slim \
node:24-bookworm-slim \
"$@"
}
@ -84,7 +84,7 @@ docker run --rm \
-e npm_config_registry=http://attacker-registry.invalid \
-e PAPERCLIP_INSTALL_TEST_LOG=/results/hostile.args \
-e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
node:22-bookworm-slim \
node:24-bookworm-slim \
bash /paperclip-scripts/install.sh --no-prompt --no-onboard
assert_line "$RESULTS_DIR/hostile.args" "--registry=https://registry.npmjs.org"
assert_line "$RESULTS_DIR/hostile.args" "NPM_CONFIG_REGISTRY=https://registry.npmjs.org"
@ -140,7 +140,7 @@ docker run --rm \
-e PAPERCLIP_INSTALL_NO_ONBOARD=1 \
-e PAPERCLIP_INSTALL_NO_PROMPT=1 \
-e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
node:22-bookworm-slim \
node:24-bookworm-slim \
bash /paperclip-scripts/install.sh
assert_line "$RESULTS_DIR/env.args" "paperclipai@2026.722.0"
assert_line "$RESULTS_DIR/env.args" "--version"
@ -161,8 +161,8 @@ assert_line "$RESULTS_DIR/no-node.args" "paperclipai@latest"
node_version="$(cat "$RESULTS_DIR/no-node.args.node")"
node_major="${node_version#v}"
node_major="${node_major%%.*}"
[ "$node_major" -ge 20 ] || {
printf 'Expected Node >= 20, got %s\n' "$node_version" >&2
[ "$node_major" -ge 24 ] || {
printf 'Expected Node >= 24, got %s\n' "$node_version" >&2
exit 1
}

View File

@ -87,7 +87,7 @@
"@types/express-serve-static-core": "^5.1.3",
"@types/jsdom": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.20.1",
"@types/node": "^24.0.0",
"@types/sharp": "^0.32.0",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.18.1",
@ -97,5 +97,8 @@
"typescript": "^5.7.3",
"vite": "^6.4.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.11.0"
}
}

View File

@ -167,7 +167,7 @@ describe("adapter routes", () => {
agentId: "codex",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=22.13.0",
nodeRange: ">=24.11.0",
packages: ["@agentclientprotocol/codex-acp"],
},
});
@ -203,7 +203,7 @@ describe("adapter routes", () => {
agentId: "gemini",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=20.0.0",
nodeRange: ">=24.11.0",
packages: ["@google/gemini-cli"],
},
});

View File

@ -243,7 +243,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
agentId: "claude",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=22.12.0",
nodeRange: ">=24.11.0",
packages: ["@agentclientprotocol/claude-agent-acp"],
},
},
@ -317,7 +317,7 @@ const codexLocalAdapter: ServerAdapterModule = {
agentId: "codex",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=22.13.0",
nodeRange: ">=24.11.0",
packages: ["@agentclientprotocol/codex-acp"],
},
},
@ -382,7 +382,7 @@ const geminiLocalAdapter: ServerAdapterModule = {
agentId: "gemini",
skillsMode: "ephemeral",
prerequisites: {
nodeRange: ">=20.0.0",
nodeRange: ">=24.11.0",
packages: ["@google/gemini-cli"],
},
},

Some files were not shown because too many files have changed in this diff Show More