diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0191f0a..28148752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,7 +108,7 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 22 + node-version: 22.23.4 - name: Compile anonymous-pipe addon run: | @@ -306,7 +306,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: ['22.21.0', '24.16.0'] + node-version: ['22.23.4', '24.22.1'] defaults: run: working-directory: packages/code @@ -343,7 +343,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 24.16.0 + node-version: 24.22.1 - run: npm ci - run: npm run build - name: Selected project root containment tests @@ -370,7 +370,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 24.16.0 + node-version: 24.22.1 - name: Install bubblewrap run: | sudo apt-get update -qq diff --git a/.github/workflows/extraction.yml b/.github/workflows/extraction.yml index 29a55217..0d9a529d 100644 --- a/.github/workflows/extraction.yml +++ b/.github/workflows/extraction.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: - node-version: 24.16.0 + node-version: 24.22.1 cache: npm cache-dependency-path: packages/extraction/package-lock.json - run: npm ci --ignore-scripts diff --git a/api/Dockerfile b/api/Dockerfile index d630c547..40429b72 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -35,7 +35,7 @@ COPY api/src/spec-guard.c /tmp/spec-guard.c COPY api/src/tool-call-pipe-bridge.c /tmp/tool-call-pipe-bridge.c COPY api/src/anonymous-pipes.c /tmp/anonymous-pipes.c COPY api/src/supervisor-policy.c /tmp/supervisor-policy.c -COPY --from=node:22-slim /usr/local/include/node /usr/local/include/node +COPY --from=node:22.23.4-slim /usr/local/include/node /usr/local/include/node COPY docker/rootfs-setup.c /tmp/rootfs-setup.c RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -I/usr/local/include/node -o /usr/local/lib/anonymous-pipes.node /tmp/anonymous-pipes.c \ && gcc -O2 -static -Wall -Wextra -Werror -o /usr/local/bin/sandbox-supervisor-policy /tmp/supervisor-policy.c \ @@ -49,7 +49,7 @@ FROM nsjail-builder AS nsjail-ipc-test ENV SANDBOX_PIPE_ADDON=/usr/local/lib/anonymous-pipes.node COPY docker/python-runtime /python-runtime RUN python3 /python-runtime/install.py -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node COPY --from=oven/bun:1.4.2-debian /usr/local/bin/bun /usr/local/bin/bun # ============================================================================ @@ -138,7 +138,7 @@ RUN chmod +x /usr/sbin/nsjail # The per-invocation broker starts Node after restore, so its OpenSSL process # state is not captured in the MicroVM snapshot. Pulling the Node binary # is ~30 MB and avoids inheriting npm/node_modules from a full apt install. -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node WORKDIR /sandbox_api diff --git a/api/src/api/session-inputs.routes.test.ts b/api/src/api/session-inputs.routes.test.ts index 48fe7d53..0ba649e4 100644 --- a/api/src/api/session-inputs.routes.test.ts +++ b/api/src/api/session-inputs.routes.test.ts @@ -26,6 +26,7 @@ let baseUrl: string; const savedSessionWorkspaceEnabled = config.session_workspace_enabled; beforeAll(async () => { + await fsp.rm(SESSION_INPUT_CACHE_DIR, { recursive: true, force: true }); config.session_workspace_enabled = true; const app = express(); /* Mirror index.ts: no global JSON parser, router mounted under /api/v2. */ diff --git a/api/src/job.ts b/api/src/job.ts index bfa99b63..88d3e5b4 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -169,8 +169,11 @@ export function ensureNodeModulesSymlink( if (!nodeModulesPath) return; const linkPath = path.join(submissionDir, 'node_modules'); try { - fs.lstatSync(linkPath); - return; + if (!fs.lstatSync(linkPath).isSymbolicLink()) return; + const target = path.resolve(submissionDir, fs.readlinkSync(linkPath)); + const packageRoot = `${path.resolve(config.packages_directory)}${path.sep}`; + if (!target.startsWith(packageRoot) || target === path.resolve(nodeModulesPath)) return; + fs.unlinkSync(linkPath); } catch (err) { if (errorCode(err) !== 'ENOENT') throw err; } diff --git a/api/src/package-init.test.ts b/api/src/package-init.test.ts new file mode 100644 index 00000000..a9431bd2 --- /dev/null +++ b/api/src/package-init.test.ts @@ -0,0 +1,198 @@ +import { expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const initializer = fs.readFileSync( + path.resolve(__dirname, '../../docker/package-init.sh'), + 'utf8' +); + +for (const oldRuntimeExists of [false, true]) { + test(`cached PVC refreshes Node and Bun launchers with ${ + oldRuntimeExists ? 'retained' : 'removed' + } old runtimes`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'cached-package-init-') + ); + try { + const packages = path.join(root, 'pkgs'); + const workspace = path.join(root, 'workspace'); + const manifest = path.join(root, 'javascript-packages.txt'); + fs.mkdirSync(workspace); + fs.writeFileSync(manifest, 'runtime-fixture@1.0.0\n'); + const runtimes = [ + { + directory: 'node/24.22.1', + binary: 'bin/node', + args: ['hello world', '--version'], + }, + { + directory: 'bun/1.4.2', + binary: 'bun', + args: ['run', 'hello world', '--version'], + }, + ]; + for (const runtime of runtimes) { + const destination = path.join(packages, runtime.directory); + const fixture = path.join( + destination, + 'node_modules/runtime-fixture' + ); + fs.mkdirSync(fixture, { recursive: true }); + fs.mkdirSync( + path.dirname(path.join(destination, runtime.binary)), + { recursive: true } + ); + fs.writeFileSync( + path.join(fixture, 'package.json'), + '{"version": "1.0.0"}' + ); + fs.writeFileSync( + path.join(destination, '.package-installed'), + 'installed' + ); + fs.writeFileSync( + path.join(destination, '.env'), + 'preserve runtime environment' + ); + fs.writeFileSync( + path.join(destination, runtime.binary), + '#!/bin/bash\nprintf "<%s>\\n" "$@"\n', + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(destination, 'run'), + '#!/bin/bash\nexit 99\n', + { mode: 0o755 } + ); + } + const python = path.join(packages, 'python/3.14.4'); + for (const name of [ + 'PIL', + 'markitdown', + 'chdb', + 'statsmodels', + 'rasterio', + ]) { + fs.mkdirSync( + path.join(python, 'lib/python3.14/site-packages', name), + { recursive: true } + ); + } + fs.writeFileSync( + path.join(python, 'lib/python3.14/.sandbox-pipe-runtime-v1'), + 'installed' + ); + fs.writeFileSync( + path.join(python, '.package-installed'), + 'installed' + ); + fs.mkdirSync(path.join(packages, 'bash/5.2.0'), { + recursive: true, + }); + fs.writeFileSync( + path.join(packages, 'bash/5.2.0/.package-installed'), + 'installed' + ); + fs.writeFileSync( + path.join(packages, '.initialized'), + 'original initialization' + ); + + const forbidden = path.join(root, 'forbidden'); + fs.mkdirSync(forbidden); + for (const binary of [ + 'curl', + 'wget', + 'make', + 'npm', + 'tar', + 'unzip', + ]) { + fs.writeFileSync( + path.join(forbidden, binary), + '#!/bin/bash\necho "unexpected reinstall" >&2\nexit 98\n', + { mode: 0o755 } + ); + } + const script = path.join(root, 'package-init.sh'); + fs.writeFileSync( + script, + initializer + .split('/pkgs') + .join(packages) + .split('/mnt/data/node_modules') + .join(path.join(workspace, 'node_modules')) + ); + const env = { + ...process.env, + PATH: `${forbidden}:${process.env.PATH}`, + FORCE_REBUILD: 'false', + PYTHON_VERSION: '3.14.4', + NODE_VERSION: '24.22.1', + BUN_VERSION: '1.4.2', + BASH_PACKAGE_VERSION: '5.2.0', + JS_PACKAGE_MANIFEST: manifest, + }; + for (let pass = 0; pass < 2; pass += 1) { + const initialized = Bun.spawnSync({ + cmd: ['bash', script], + env, + }); + expect(initialized.stderr.toString()).toBe(''); + expect(initialized.exitCode).toBe(0); + expect(initialized.stdout.toString()).toContain( + 'Packages already initialized' + ); + expect( + fs.readFileSync(path.join(packages, '.initialized'), 'utf8') + ).toBe('original initialization'); + for (const runtime of runtimes) { + const destination = path.join(packages, runtime.directory); + const oldModules = path.join( + packages, + runtime.directory.split('/')[0], + '0.0.1/node_modules' + ); + if (oldRuntimeExists) + fs.mkdirSync(oldModules, { recursive: true }); + const link = path.join(workspace, 'node_modules'); + fs.rmSync(link, { force: true }); + fs.symlinkSync(oldModules, link); + const launched = Bun.spawnSync({ + cmd: [ + path.join(destination, 'run'), + 'hello world', + '--version', + ], + }); + expect(launched.stderr.toString()).toBe(''); + expect(launched.exitCode).toBe(0); + expect(launched.stdout.toString()).toBe( + runtime.args.map(arg => `<${arg}>\n`).join('') + ); + expect(fs.readlinkSync(link)).toBe( + path.join(destination, 'node_modules') + ); + expect( + fs.readFileSync( + path.join(destination, '.package-installed'), + 'utf8' + ) + ).toBe('installed'); + expect( + fs.readFileSync(path.join(destination, '.env'), 'utf8') + ).toBe('preserve runtime environment'); + expect( + fs + .readdirSync(destination) + .some(name => name.startsWith('.run.')) + ).toBe(false); + } + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/api/src/runtime-module-links.test.ts b/api/src/runtime-module-links.test.ts new file mode 100644 index 00000000..6ef754ab --- /dev/null +++ b/api/src/runtime-module-links.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { config } from './config'; +import { ensureNodeModulesSymlink } from './job'; + +function fixture( + run: (workspace: string, packages: string, modules: string) => void +): void { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'runtime-module-links-') + ); + const originalPackages = config.packages_directory; + const workspace = path.join(root, 'workspace'); + const packages = path.join(root, 'pkgs'); + const modules = path.join(packages, 'node', '24.22.1', 'node_modules'); + fs.mkdirSync(workspace); + fs.mkdirSync(modules, { recursive: true }); + config.packages_directory = packages; + try { + run(workspace, packages, modules); + } finally { + config.packages_directory = originalPackages; + fs.rmSync(root, { recursive: true, force: true }); + } +} + +const managers = [ + { + name: 'bash job preparation', + prepare: ensureNodeModulesSymlink, + }, +]; +for (const file of ['build-packages.sh', 'docker/package-init.sh']) { + const source = fs + .readFileSync(path.resolve(__dirname, '../..', file), 'utf8') + .replace(/\\(["$])/g, '$1'); + const launchers = + source.match(/MODULE_DIR=.*\nif \[ -d [\s\S]*?\nfi/g) ?? []; + expect(launchers).toHaveLength(2); + for (const [index, launcher] of launchers.entries()) { + managers.push({ + name: `${file} ${index === 0 ? 'Node' : 'Bun'} launcher`, + prepare(workspace: string, modules?: string): void { + const link = path.join(workspace, 'node_modules'); + const script = `SCRIPT_DIR=$1\n${launcher + .split('/mnt/data/node_modules') + .join('"$2"')}`; + const result = Bun.spawnSync({ + cmd: [ + 'bash', + '-c', + script, + 'runtime-launcher', + path.dirname(modules!), + link, + ], + }); + expect(result.exitCode).toBe(0); + }, + }); + } +} + +for (const manager of managers) { + test(`${manager.name}: links bundled modules into a fresh workspace`, () => { + fixture((workspace, _packages, modules) => { + manager.prepare(workspace, modules); + expect(fs.readlinkSync(path.join(workspace, 'node_modules'))).toBe( + modules + ); + }); + }); + + for (const oldRuntimeExists of [false, true]) { + test(`${manager.name}: refreshes a ${ + oldRuntimeExists ? 'retained' : 'removed' + } runtime link`, () => { + fixture((workspace, packages, modules) => { + const oldModules = path.join( + packages, + 'node', + '24.15.0', + 'node_modules' + ); + if (oldRuntimeExists) + fs.mkdirSync(oldModules, { recursive: true }); + fs.symlinkSync( + oldModules, + path.join(workspace, 'node_modules') + ); + manager.prepare(workspace, modules); + expect( + fs.readlinkSync(path.join(workspace, 'node_modules')) + ).toBe(modules); + }); + }); + } + + test(`${manager.name}: leaves the current runtime link unchanged`, () => { + fixture((workspace, _packages, modules) => { + const link = path.join(workspace, 'node_modules'); + fs.symlinkSync(modules, link); + const inode = fs.lstatSync(link).ino; + manager.prepare(workspace, modules); + expect(fs.lstatSync(link).ino).toBe(inode); + }); + }); + + test(`${manager.name}: preserves a user-managed directory`, () => { + fixture((workspace, _packages, modules) => { + const link = path.join(workspace, 'node_modules'); + fs.mkdirSync(link); + fs.writeFileSync(path.join(link, 'user-package'), 'keep'); + manager.prepare(workspace, modules); + expect( + fs.readFileSync(path.join(link, 'user-package'), 'utf8') + ).toBe('keep'); + }); + }); + + test(`${manager.name}: preserves a valid user link sharing the package-root prefix`, () => { + fixture((workspace, packages, modules) => { + const target = `${packages}-other/node_modules`; + const link = path.join(workspace, 'node_modules'); + fs.mkdirSync(target, { recursive: true }); + fs.symlinkSync(target, link); + manager.prepare(workspace, modules); + expect(fs.readlinkSync(link)).toBe(target); + }); + }); + + test(`${manager.name}: preserves a user link with a package-root traversal`, () => { + fixture((workspace, packages, modules) => { + const target = `${packages}/../user/node_modules`; + const link = path.join(workspace, 'node_modules'); + fs.mkdirSync(path.resolve(target), { recursive: true }); + fs.symlinkSync(target, link); + manager.prepare(workspace, modules); + expect(fs.readlinkSync(link)).toBe(target); + }); + }); + + for (const target of [ + '../user/node_modules', + '../pkgs-other/node_modules', + ]) { + test(`${manager.name}: preserves user link ${target}`, () => { + fixture((workspace, _packages, modules) => { + const link = path.join(workspace, 'node_modules'); + fs.symlinkSync(target, link); + manager.prepare(workspace, modules); + expect(fs.readlinkSync(link)).toBe(target); + }); + }); + } +} + +test('restored Node sessions resolve ESM packages after the old runtime is removed', () => { + fixture((workspace, packages, modules) => { + const oldModules = path.join( + packages, + 'node', + '24.15.0', + 'node_modules' + ); + fs.symlinkSync(oldModules, path.join(workspace, 'node_modules')); + const archive = path.join(path.dirname(workspace), 'checkpoint.tar'); + expect( + Bun.spawnSync({ + cmd: ['tar', '-cf', archive, '-C', workspace, '.'], + }).exitCode + ).toBe(0); + fs.rmSync(workspace, { recursive: true }); + fs.mkdirSync(workspace); + expect( + Bun.spawnSync({ cmd: ['tar', '-xf', archive, '-C', workspace] }) + .exitCode + ).toBe(0); + const pkg = path.join(modules, 'runtime-fixture'); + fs.mkdirSync(pkg); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ type: 'module', exports: './index.js' }) + ); + fs.writeFileSync( + path.join(pkg, 'index.js'), + 'export default "upgraded";' + ); + + ensureNodeModulesSymlink(workspace, modules); + + const result = Bun.spawnSync({ + cmd: [ + process.env.NODE_RUNTIME_TEST_BINARY ?? 'node', + '--input-type=module', + '-e', + 'import value from "runtime-fixture"; console.log(value);', + ], + cwd: workspace, + }); + expect(result.stderr.toString()).toBe(''); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe('upgraded'); + }); +}); diff --git a/api/src/workspace-command.test.ts b/api/src/workspace-command.test.ts index 38936a3c..e2a9eecb 100644 --- a/api/src/workspace-command.test.ts +++ b/api/src/workspace-command.test.ts @@ -1,10 +1,11 @@ -import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; import semver from 'semver'; -import type { Runtime } from './runtime'; +import { config } from './config'; +import { getRuntimes, type Runtime } from './runtime'; import { executeWorkspaceCommand, hasWorkspaceCommandToken, @@ -79,6 +80,80 @@ describe('sandboxed workspace commands', () => { } }); + for (const existingModules of ['absent', 'removed', 'retained', 'directory', 'external']) { + test(`prepares ${existingModules} modules before workspace-command ESM execution`, async () => { + const parent = await mkdtemp(join(tmpdir(), 'workspace-command-modules-')); + const savedPackages = config.packages_directory; + const registered = getRuntimes(); + const savedRuntimes = registered.splice(0); + try { + const packages = join(parent, 'pkgs'); + const root = join(parent, 'workspace'); + const modules = join(packages, 'node/24.22.1/node_modules'); + const link = join(root, 'node_modules'); + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(join(modules, 'runtime-fixture'), { recursive: true }); + await writeFile(join(modules, 'runtime-fixture/package.json'), JSON.stringify({ type: 'module', exports: './index.js' })); + await writeFile(join(modules, 'runtime-fixture/index.js'), 'export default "upgraded";'); + config.packages_directory = packages; + registered.push({ + ...runtime, + language: 'node', + version: new semver.SemVer('24.22.1'), + pkgdir: join(packages, 'node/24.22.1'), + env_vars: { NODE_PATH: modules }, + }); + let userTarget: string | undefined; + if (existingModules === 'directory') { + await mkdir(link); + await writeFile(join(link, 'keep'), 'user module'); + } else if (existingModules === 'external') { + userTarget = join(packages, '../user/node_modules'); + await mkdir(userTarget, { recursive: true }); + await symlink(userTarget, link); + } else if (existingModules !== 'absent') { + const oldModules = join(packages, 'node/24.15.0/node_modules'); + if (existingModules === 'retained') await mkdir(oldModules, { recursive: true }); + await symlink(oldModules, link); + } + let executed = false; + await executeWorkspaceCommand({ command: 'node app.mjs', cwd: 'src' }, { + workspaceRoot: root, + runtime, + executeNsJail: async options => { + executed = true; + expect(options.extraPkgdirs).toContain(join(packages, 'node/24.22.1')); + expect(options.envVars.NODE_PATH).toBe(modules); + if (existingModules === 'directory') { + expect(await readFile(join(link, 'keep'), 'utf8')).toBe('user module'); + } else if (existingModules === 'external') { + expect(await readlink(link)).toBe(userTarget!); + } else { + expect(await readlink(link)).toBe(modules); + const result = Bun.spawnSync({ + cmd: [process.env.NODE_RUNTIME_TEST_BINARY ?? 'node', '--input-type=module', '-e', + 'import value from "runtime-fixture"; console.log(value);'], + cwd: join(root, 'src'), + }); + expect(result.stderr.toString()).toBe(''); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe('upgraded'); + } + return { + stdout: '', stderr: '', code: 0, signal: null, output: '', + memory: null, message: null, status: null, cpu_time: null, wall_time: 1, + }; + }, + }); + expect(executed).toBe(true); + } finally { + registered.splice(0, registered.length, ...savedRuntimes); + config.packages_directory = savedPackages; + await rm(parent, { recursive: true, force: true }); + } + }); + } + test('rejects a cwd symlink that escapes the mounted workspace', async () => { const parent = await mkdtemp(join(tmpdir(), 'workspace-command-')); try { diff --git a/api/src/workspace-command.ts b/api/src/workspace-command.ts index a2cd5339..34180da9 100644 --- a/api/src/workspace-command.ts +++ b/api/src/workspace-command.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import * as fsp from 'node:fs/promises'; import { timingSafeEqual } from 'node:crypto'; -import { aggregateBashExtras } from './job'; +import { aggregateBashExtras, ensureNodeModulesSymlink } from './job'; import { execute, type NsJailResult } from './nsjail'; import { getLatestRuntimeMatchingLanguageVersion } from './runtime'; import type { Runtime } from './runtime'; @@ -169,7 +169,9 @@ export async function executeWorkspaceCommand( HOME: '/mnt/data', SANDBOX_LANGUAGE: 'bash', }; - const extraPkgdirs = aggregateBashExtras(runtime.pkgdir, envVars); + const linkTarget: { nodeModulesPath?: string } = {}; + const extraPkgdirs = aggregateBashExtras(runtime.pkgdir, envVars, undefined, linkTarget); + ensureNodeModulesSymlink(workspaceRoot, linkTarget.nodeModulesPath); const relativeCwd = path.relative(workspaceRoot, cwd).split(path.sep).join('/'); const result = await (options.executeNsJail ?? execute)({ command: [ diff --git a/build-packages.sh b/build-packages.sh index 1d635297..59d54750 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -9,7 +9,7 @@ # # Environment Variables: # PYTHON_VERSION=3.14.4 # Python version to install -# NODE_VERSION=24.15.0 # Node.js version to install +# NODE_VERSION=24.22.1 # Node.js version to install # BUN_VERSION=1.4.2 # Bun version to install # BASH_PACKAGE_VERSION=5.2.0 # Bash package registration version (semver-like x.y.z) # SKIP_PYTHON_PACKAGES=1 # Skip Python pip packages @@ -27,7 +27,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" PYTHON_VERSION="${PYTHON_VERSION:-3.14.4}" -NODE_VERSION="${NODE_VERSION:-24.15.0}" +NODE_VERSION="${NODE_VERSION:-24.22.1}" BUN_VERSION="${BUN_VERSION:-1.4.2}" PACKAGES_DIR="./data/pkgs" JS_PACKAGE_MANIFEST="${JS_PACKAGE_MANIFEST:-${SCRIPT_DIR}/javascript-packages.txt}" @@ -271,8 +271,18 @@ EOF" #!/bin/bash SCRIPT_DIR=\"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)\" MODULE_DIR=\"\${SCRIPT_DIR}/node_modules\" -if [ -d \"\${MODULE_DIR}\" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true +if [ -d \"\${MODULE_DIR}\" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET=\"\$(readlink -m /mnt/data/node_modules)\" + if [ \"\${LINK_TARGET}\" != \"\${MODULE_DIR}\" ]; then + case \"\${LINK_TARGET}\" in + \"\${SCRIPT_DIR%/*/*}/\"*/node_modules) + ln -sfnT \"\${MODULE_DIR}\" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true + fi fi \"\${SCRIPT_DIR}/bin/node\" \"\$@\" EOF @@ -373,8 +383,18 @@ EOF" #!/bin/bash SCRIPT_DIR=\"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)\" MODULE_DIR=\"\${SCRIPT_DIR}/node_modules\" -if [ -d \"\${MODULE_DIR}\" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true +if [ -d \"\${MODULE_DIR}\" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET=\"\$(readlink -m /mnt/data/node_modules)\" + if [ \"\${LINK_TARGET}\" != \"\${MODULE_DIR}\" ]; then + case \"\${LINK_TARGET}\" in + \"\${SCRIPT_DIR%/*/*}/\"*/node_modules) + ln -sfnT \"\${MODULE_DIR}\" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true + fi fi \"\${SCRIPT_DIR}/bun\" run \"\$@\" EOF diff --git a/docker/Dockerfile.worker-sandbox b/docker/Dockerfile.worker-sandbox index 673dd42f..7f94409c 100644 --- a/docker/Dockerfile.worker-sandbox +++ b/docker/Dockerfile.worker-sandbox @@ -48,7 +48,7 @@ COPY api/src/spec-guard.c /tmp/spec-guard.c COPY api/src/tool-call-pipe-bridge.c /tmp/tool-call-pipe-bridge.c COPY api/src/anonymous-pipes.c /tmp/anonymous-pipes.c COPY api/src/supervisor-policy.c /tmp/supervisor-policy.c -COPY --from=node:22-slim /usr/local/include/node /usr/local/include/node +COPY --from=node:22.23.4-slim /usr/local/include/node /usr/local/include/node COPY docker/rootfs-setup.c /tmp/rootfs-setup.c RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -I/usr/local/include/node -o /usr/local/lib/anonymous-pipes.node /tmp/anonymous-pipes.c \ && gcc -O2 -static -Wall -Wextra -Werror -o /usr/local/bin/sandbox-supervisor-policy /tmp/supervisor-policy.c \ @@ -160,7 +160,7 @@ COPY --from=nsjail-builder /usr/local/bin/sandbox-supervisor-policy /usr/local/b COPY --from=nsjail-builder /usr/local/lib/anonymous-pipes.node /usr/local/lib/anonymous-pipes.node ENV SANDBOX_PIPE_ADDON=/usr/local/lib/anonymous-pipes.node RUN chmod +x /usr/sbin/nsjail -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node WORKDIR /sandbox_api diff --git a/docker/package-init.sh b/docker/package-init.sh index 4b96305f..2273721c 100644 --- a/docker/package-init.sh +++ b/docker/package-init.sh @@ -20,7 +20,7 @@ PYTHON_ALIAS="python${PYTHON_SITE_VERSION}" # below — that's inherent to installing unpinned packages, not specific to uv. PYTHON_PACKAGE_INSTALLER="${PYTHON_PACKAGE_INSTALLER:-uv}" UV_VERSION="${UV_VERSION:-0.11.26}" -NODE_VERSION="${NODE_VERSION:-24.15.0}" +NODE_VERSION="${NODE_VERSION:-24.22.1}" BUN_VERSION="${BUN_VERSION:-1.4.2}" BASH_PACKAGE_VERSION="${BASH_PACKAGE_VERSION:-5.2.0}" INSTALL_FAILED=false @@ -101,8 +101,64 @@ packages_ready() { [ -f "/pkgs/bash/${BASH_PACKAGE_VERSION}/.package-installed" ] } +write_node_launcher() { + local destination="$1" + local launcher + launcher="$(mktemp "${destination}/.run.XXXXXX")" + cat > "$launcher" << 'EOF' +#!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="${SCRIPT_DIR}/node_modules" +if [ -d "$MODULE_DIR" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET="$(readlink -m /mnt/data/node_modules)" + if [ "$LINK_TARGET" != "$MODULE_DIR" ]; then + case "$LINK_TARGET" in + "${SCRIPT_DIR%/*/*}/"*/node_modules) + ln -sfnT "$MODULE_DIR" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true + fi +fi +"${SCRIPT_DIR}/bin/node" "$@" +EOF + chmod 0755 "$launcher" + mv -f "$launcher" "${destination}/run" +} + +write_bun_launcher() { + local destination="$1" + local launcher + launcher="$(mktemp "${destination}/.run.XXXXXX")" + cat > "$launcher" << 'EOF' +#!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="${SCRIPT_DIR}/node_modules" +if [ -d "$MODULE_DIR" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET="$(readlink -m /mnt/data/node_modules)" + if [ "$LINK_TARGET" != "$MODULE_DIR" ]; then + case "$LINK_TARGET" in + "${SCRIPT_DIR%/*/*}/"*/node_modules) + ln -sfnT "$MODULE_DIR" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true + fi +fi +"${SCRIPT_DIR}/bun" run "$@" +EOF + chmod 0755 "$launcher" + mv -f "$launcher" "${destination}/run" +} + if [ -f "$MARKER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then if packages_ready; then + write_node_launcher "/pkgs/node/${NODE_VERSION}" + write_bun_launcher "/pkgs/bun/${BUN_VERSION}" echo "Packages already initialized (marker file exists)" echo "Set FORCE_REBUILD=true to force reinstall" echo "" @@ -301,16 +357,7 @@ if [ -n "$NODE_ARCH" ]; then } EOF - cat > "$NODE_DEST/run" << 'EOF' -#!/bin/bash -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MODULE_DIR="${SCRIPT_DIR}/node_modules" -if [ -d "$MODULE_DIR" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true -fi -"${SCRIPT_DIR}/bin/node" "$@" -EOF - chmod +x "$NODE_DEST/run" + write_node_launcher "$NODE_DEST" { echo "PATH=${NODE_DEST}/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:." @@ -413,16 +460,7 @@ if [ -n "$BUN_ARCH" ]; then } EOF - cat > "$BUN_DEST/run" << 'EOF' -#!/bin/bash -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MODULE_DIR="${SCRIPT_DIR}/node_modules" -if [ -d "$MODULE_DIR" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true -fi -"${SCRIPT_DIR}/bun" run "$@" -EOF - chmod +x "$BUN_DEST/run" + write_bun_launcher "$BUN_DEST" { echo "PATH=${BUN_DEST}:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:." diff --git a/helm/codeapi/values.yaml b/helm/codeapi/values.yaml index 4858fd71..beca089d 100644 --- a/helm/codeapi/values.yaml +++ b/helm/codeapi/values.yaml @@ -310,7 +310,7 @@ workerSandbox: tag: latest pullPolicy: Always pythonVersion: "3.14.4" - nodeVersion: "24.15.0" + nodeVersion: "24.22.1" bunVersion: "1.4.2" bashPackageVersion: "5.2.0" backoffLimit: 2 diff --git a/launcher/Dockerfile b/launcher/Dockerfile index bad5cd2f..f7c305fb 100644 --- a/launcher/Dockerfile +++ b/launcher/Dockerfile @@ -40,7 +40,7 @@ COPY api/src/spec-guard.c /tmp/spec-guard.c COPY api/src/tool-call-pipe-bridge.c /tmp/tool-call-pipe-bridge.c COPY api/src/anonymous-pipes.c /tmp/anonymous-pipes.c COPY api/src/supervisor-policy.c /tmp/supervisor-policy.c -COPY --from=node:22-slim /usr/local/include/node /usr/local/include/node +COPY --from=node:22.23.4-slim /usr/local/include/node /usr/local/include/node COPY docker/rootfs-setup.c /tmp/rootfs-setup.c RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -I/usr/local/include/node -o /usr/local/lib/anonymous-pipes.node /tmp/anonymous-pipes.c \ && gcc -O2 -static -Wall -Wextra -Werror -o /usr/local/bin/sandbox-supervisor-policy /tmp/supervisor-policy.c \ @@ -79,7 +79,7 @@ COPY --from=nsjail-builder /usr/local/bin/tool-call-pipe-bridge /usr/local/bin/t COPY --from=nsjail-builder /usr/local/bin/sandbox-supervisor-policy /usr/local/bin/sandbox-supervisor-policy COPY --from=nsjail-builder /usr/local/lib/anonymous-pipes.node /usr/local/lib/anonymous-pipes.node ENV SANDBOX_PIPE_ADDON=/usr/local/lib/anonymous-pipes.node -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node RUN chmod +x /usr/sbin/nsjail WORKDIR /sandbox_api diff --git a/packages/code/Dockerfile b/packages/code/Dockerfile index 93ed9c57..34c54899 100644 --- a/packages/code/Dockerfile +++ b/packages/code/Dockerfile @@ -1,11 +1,11 @@ -FROM node:24-alpine AS build +FROM node:24.22.1-alpine AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json ./ RUN npm ci COPY src ./src RUN npm run build -FROM node:24-alpine +FROM node:24.22.1-alpine ENV NODE_ENV=production RUN apk add --no-cache bash bubblewrap ripgrep socat \ && addgroup -S librechat-code \ diff --git a/packages/extraction/Dockerfile b/packages/extraction/Dockerfile index 4acf5d9c..cfc48c14 100644 --- a/packages/extraction/Dockerfile +++ b/packages/extraction/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24.16.0-bookworm-slim AS build +FROM node:24.22.1-bookworm-slim AS build WORKDIR /build COPY packages/extraction/package*.json ./ RUN npm ci --ignore-scripts --no-audit --no-fund diff --git a/service/Dockerfile.node b/service/Dockerfile.node index 8a6006e4..dc964fce 100644 --- a/service/Dockerfile.node +++ b/service/Dockerfile.node @@ -1,5 +1,5 @@ # Base stage for shared steps -FROM node:22-slim AS base +FROM node:22.23.4-slim AS base WORKDIR /app COPY service/package*.json ./ RUN npm ci @@ -13,7 +13,7 @@ COPY service/tsconfig.json ./ RUN npx tsc -p tsconfig.json # Production stage -FROM node:22-slim AS production +FROM node:22.23.4-slim AS production ENV NODE_ENV=production WORKDIR /app COPY service/package*.json ./ diff --git a/service/src/config.spec.ts b/service/src/config.spec.ts index 3c2ca9d2..45ae5700 100644 --- a/service/src/config.spec.ts +++ b/service/src/config.spec.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'fs'; +import * as path from 'path'; import { jobCompletionWaitTimeoutMs, jobDeadlineAtMs, @@ -22,11 +24,22 @@ describe('node language configuration', () => { it('maps Node.js requests to the node sandbox runtime', () => { expect(languageConfig[Languages.node]).toEqual({ language: 'node', - version: '24.15.0', + version: '24.22.1', fileName: 'index.js', }); }); + it('aligns package installers and Helm with the selected Node runtime', () => { + const root = path.resolve(__dirname, '../..'); + const version = languageConfig[Languages.node]!.version; + for (const file of ['build-packages.sh', 'docker/package-init.sh']) { + expect(readFileSync(path.join(root, file), 'utf8')) + .toContain(`NODE_VERSION="\${NODE_VERSION:-${version}}"`); + } + expect(readFileSync(path.join(root, 'helm/codeapi/values.yaml'), 'utf8')) + .toContain(`nodeVersion: "${version}"`); + }); + it('creates a valid Node.js execution payload', () => { const req = { body: { @@ -45,7 +58,7 @@ describe('node language configuration', () => { expect(payload).toMatchObject({ language: 'node', - version: '24.15.0', + version: '24.22.1', session_id: 'session-node', args: ['--trace-warnings'], files: [ diff --git a/service/src/config.ts b/service/src/config.ts index b3e397b8..4c78f2db 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -11,7 +11,7 @@ import { export const languageConfig: Record = { [Languages.bash]: { language: 'bash', version: '5.2.0', fileName: 'script.sh' }, [Languages.js]: { language: 'bun-js', version: '1.4.2', fileName: 'index.js' }, - [Languages.node]: { language: 'node', version: '24.15.0', fileName: 'index.js' }, + [Languages.node]: { language: 'node', version: '24.22.1', fileName: 'index.js' }, [Languages.py]: { language: 'python', version: '3.14.4', fileName: 'main.py' }, [Languages.ts]: { language: 'bun-ts', version: '1.4.2', fileName: 'main.ts' }, }; diff --git a/tests/package_init_runtime_rollout.sh b/tests/package_init_runtime_rollout.sh index 3bb8cf6c..9c09d542 100755 --- a/tests/package_init_runtime_rollout.sh +++ b/tests/package_init_runtime_rollout.sh @@ -15,6 +15,11 @@ render() { --set workerSandbox.enabled=true \ --set workerSandbox.packages.source=pvc "$@" } +# The hook installs the runtime selected by service requests. +render | grep -A 1 'name: NODE_VERSION' | grep -q 'value: "24.22.1"' +# Explicit runtime selections still override the chart default. +render --set workerSandbox.packages.initJob.nodeVersion=24.22.0 \ + | grep -A 1 'name: NODE_VERSION' | grep -q 'value: "24.22.0"' # Default mutable image must refresh on every pre-install/pre-upgrade hook. render | grep -q 'imagePullPolicy: Always' # Reused historical values must not bypass the runtime migration.