From 6fbe175f7be4bf9b3f03ab205d8fd6f542607c2a Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:45:39 +0200 Subject: [PATCH 1/3] fix(retention): enforce Node 22 operator runtime --- docs/VERCEL-RETENTION-RUNNER.md | 9 ++ scripts/vercel-retention/cli.mjs | 14 ++ .../support/vercelRetentionFixture.mjs | 11 ++ tests/unit/tooling/vercelRetention.test.mjs | 12 +- tests/unit/tooling/vercelRetention.test.ts | 10 +- .../tooling/vercelRetentionRuntime.test.mjs | 127 ++++++++++++++++++ .../tooling/vercelRetentionSafety.test.mjs | 9 +- 7 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 tests/unit/tooling/vercelRetentionRuntime.test.mjs diff --git a/docs/VERCEL-RETENTION-RUNNER.md b/docs/VERCEL-RETENTION-RUNNER.md index 94a9c346e..0696c5071 100644 --- a/docs/VERCEL-RETENTION-RUNNER.md +++ b/docs/VERCEL-RETENTION-RUNNER.md @@ -8,6 +8,14 @@ record reconciler ([`PAGES-DEPLOYMENT-RECORD-PRUNING.md`](PAGES-DEPLOYMENT-RECOR ## Commands +Run the operator with Node **22.19.0 or a later 22.x patch/minor**, matching `.nvmrc`. +Both entry points check `process.versions.node` before source checks, external tools, runner +imports or lock acquisition. Node 24 and malformed/unsupported runtimes stop with +`RETENTION_STOP: runtime-mismatch`; there is no override. The application's Node 22/24 CI matrix +and general package engine range are unchanged. In a VS Code terminal, activate Node 22 first +and check `node -p 'process.versions.node'`; a shell's separate `node --version` is not proof of +an already running operator's runtime. + Two fixed commands, no arguments. Any other argument is refused, so a command-line allow rule for them cannot be widened by appending text. @@ -141,6 +149,7 @@ authentication stop, not something an allow rule fixes. | Reason | Meaning | | --- | --- | +| `runtime-mismatch` | The actual operator runtime is not Node 22 at or above 22.19.0. No external call or lock acquisition occurs. | | `gate-not-green:` | A required push run on `main` for the exact SHA is missing, in flight or not a success. | | `runs-unreadable`, `runs-incomplete` | The run list could not be read, or was as long as the request limit and may be truncated. | | `invalid-holds` | The hold file is missing, malformed or lists something other than deployment IDs. | diff --git a/scripts/vercel-retention/cli.mjs b/scripts/vercel-retention/cli.mjs index 78270cf52..bb30c0cb8 100644 --- a/scripts/vercel-retention/cli.mjs +++ b/scripts/vercel-retention/cli.mjs @@ -13,6 +13,19 @@ const stop = (reason) => { throw Object.assign(new Error(reason), { reason }); }; +// QNBS-v3: retention is a Node 22 operator contract, independent of the application's Node 22/24 CI matrix; reject the actual runtime before tools, locks or runner imports. +export function assertRuntime(version = process.versions.node) { + const match = typeof version === 'string' && /^22\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + if ( + !match || + match[0] !== version || + !Number.isSafeInteger(Number(match[1])) || + !Number.isSafeInteger(Number(match[2])) || + Number(match[1]) < 19 + ) + stop('runtime-mismatch'); +} + function parse(text, what) { try { return JSON.parse(text); @@ -71,6 +84,7 @@ export function report(io, summary) { // QNBS-v3: the manifest is printed from the plan, before the first write, so an operator can read exactly what a reconcile pass is about to do; the summary line follows the pass. export async function main(argv, deps, io = console) { + assertRuntime(); const mode = parseMode(argv); if (mode === 'reconcile') assertTrustedSource(deps); const { runRetention } = await import('./apply.mjs'); diff --git a/tests/unit/tooling/support/vercelRetentionFixture.mjs b/tests/unit/tooling/support/vercelRetentionFixture.mjs index db13f79f2..f219610b7 100644 --- a/tests/unit/tooling/support/vercelRetentionFixture.mjs +++ b/tests/unit/tooling/support/vercelRetentionFixture.mjs @@ -106,6 +106,17 @@ export function standardWorld() { }; } +// QNBS-v3: behavior fixtures exercise the supported operator runtime even in the application Node 24 matrix; separate subprocess tests cover the real runtime refusal. +export async function withRetentionRuntime(action) { + const descriptor = Object.getOwnPropertyDescriptor(process.versions, 'node'); + Object.defineProperty(process.versions, 'node', { ...descriptor, value: '22.23.3' }); + try { + return await action(); + } finally { + Object.defineProperty(process.versions, 'node', descriptor); + } +} + const stamp = (item) => item.createdAt ?? item.created; function page(items, params, key, size) { diff --git a/tests/unit/tooling/vercelRetention.test.mjs b/tests/unit/tooling/vercelRetention.test.mjs index 237d340ac..d86e92973 100644 --- a/tests/unit/tooling/vercelRetention.test.mjs +++ b/tests/unit/tooling/vercelRetention.test.mjs @@ -21,6 +21,7 @@ import { production, SECRET, standardWorld, + withRetentionRuntime, } from './support/vercelRetentionFixture.mjs'; const OTHER = 'c'.repeat(40); @@ -385,7 +386,9 @@ test('a modified, unmerged or divergent runner never mutates', async () => { test('reconcile from an untrusted checkout performs no provider call at all', async () => { const world = standardWorld(); world.differs = true; - const error = await stop(() => main(['reconcile'], makeDeps(world), { log() {} })); + const error = await stop(() => + withRetentionRuntime(() => main(['reconcile'], makeDeps(world), { log() {} })), + ); assert.equal(error.reason, 'runner-source-differs-from-main'); assert.ok(world.calls.every((call) => !call.startsWith('vercel'))); }); @@ -426,7 +429,8 @@ test('the command line refuses an unknown mode before touching any tool', () => }); assert.equal(result.status, 1); assert.equal(result.stdout, ''); - assert.match(result.stderr, /RETENTION_STOP: invalid-arguments/); + const reason = process.versions.node.startsWith('22.') ? 'invalid-arguments' : 'runtime-mismatch'; + assert.ok(result.stderr.includes(`RETENTION_STOP: ${reason}`)); }); test('mutations accept only an exact deployment ID or alias host and always pass --safe', () => { @@ -522,7 +526,9 @@ test('a provider page without pagination metadata is malformed, not the final pa test('the dry-run reports one manifest line per preview with its verdict and reasons', async () => { const lines = []; - await main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) }); + await withRetentionRuntime(() => + main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) }), + ); const manifest = lines.filter((line) => line.startsWith('RETENTION_MANIFEST')); assert.equal(manifest.length, 8); assert.ok(manifest.some((line) => line.includes('dpl_prev1 | SAFE'))); diff --git a/tests/unit/tooling/vercelRetention.test.ts b/tests/unit/tooling/vercelRetention.test.ts index 4cd0a87b0..4a4db05d0 100644 --- a/tests/unit/tooling/vercelRetention.test.ts +++ b/tests/unit/tooling/vercelRetention.test.ts @@ -4,10 +4,12 @@ import { fileURLToPath } from 'node:url'; import { expect, it } from 'vitest'; // QNBS-v3: the lightweight Node suite runs standalone locally and through required Vitest CI without importing its node:test runner into Vitest. -it('passes the standalone Vercel retention authority, classification, mutation, lock and entry-point regressions', () => { - const paths = ['./vercelRetention.test.mjs', './vercelRetentionSafety.test.mjs'].map((name) => - fileURLToPath(new URL(name, import.meta.url)), - ); +it('passes the standalone Vercel retention authority, classification, mutation, lock, entry-point and runtime regressions', () => { + const paths = [ + './vercelRetention.test.mjs', + './vercelRetentionSafety.test.mjs', + './vercelRetentionRuntime.test.mjs', + ].map((name) => fileURLToPath(new URL(name, import.meta.url))); const output = execFileSync(process.execPath, ['--test', '--test-reporter=tap', ...paths], { encoding: 'utf8', timeout: 60000, diff --git a/tests/unit/tooling/vercelRetentionRuntime.test.mjs b/tests/unit/tooling/vercelRetentionRuntime.test.mjs new file mode 100644 index 000000000..08d5db90b --- /dev/null +++ b/tests/unit/tooling/vercelRetentionRuntime.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { assertRuntime, main } from '../../../scripts/vercel-retention/cli.mjs'; +import { + makeDeps, + standardWorld, + withRetentionRuntime, +} from './support/vercelRetentionFixture.mjs'; + +const cli = fileURLToPath(new URL('../../../scripts/vercel-retention/cli.mjs', import.meta.url)); + +test('the operator contract matches .nvmrc without narrowing application engines or CI', () => { + assert.equal(readFileSync(new URL('../../../.nvmrc', import.meta.url), 'utf8').trim(), '22'); + const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')); + assert.equal(pkg.engines.node, '>=22.19.0'); + for (const version of ['22.19.0', '22.19.1', '22.23.3', '22.100.0']) + assert.doesNotThrow(() => assertRuntime(version)); + for (const version of [ + null, + '', + 'unknown', + 'v22.23.3', + '22.18.99', + '22.19', + '22.19.0-rc.1', + '22.19.0\n', + '22.019.0', + '22.19.00', + '22.19.9007199254740992', + '22.9007199254740992.0', + '20.19.0', + '21.0.0', + '23.0.0', + '24.18.0', + ]) { + assert.throws(() => assertRuntime(version), { reason: 'runtime-mismatch' }); + } +}); + +function guardedChild(argv, version) { + // QNBS-v3: test-only preload instruments every CLI/lock boundary and runner resolution; production has no runtime override or environment escape hatch. + const preload = ` + import cp from 'node:child_process'; + import fs from 'node:fs'; + import { registerHooks, syncBuiltinESMExports } from 'node:module'; + const calls = { child: 0, lock: 0, runner: 0, fetch: 0 }; + const deny = (kind) => () => { calls[kind]++; throw new Error('unexpected-side-effect'); }; + cp.execFileSync = deny('child'); + for (const key of ['writeFileSync', 'linkSync', 'renameSync', 'rmSync']) fs[key] = deny('lock'); + globalThis.fetch = deny('fetch'); + syncBuiltinESMExports(); + registerHooks({ resolve(specifier, context, next) { + if (specifier === './apply.mjs') { calls.runner++; throw new Error('runner-loaded'); } + return next(specifier, context); + }}); + ${version === undefined ? '' : `Object.defineProperty(process.versions, 'node', {value: ${JSON.stringify(version)}});`} + process.on('exit', () => process.stderr.write('GUARD_CALLS ' + JSON.stringify(calls) + '\\n')); + `; + const result = spawnSync( + process.execPath, + [`--import=data:text/javascript,${encodeURIComponent(preload)}`, cli, ...argv], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.ifError(result.error); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + const counts = /GUARD_CALLS (\{[^\n]+\})/.exec(result.stderr); + assert.ok(counts, result.stderr); + assert.deepEqual(JSON.parse(counts[1]), { child: 0, lock: 0, runner: 0, fetch: 0 }); + return result.stderr; +} + +for (const mode of ['dry-run', 'reconcile']) { + test(`${mode} rejects unsupported and malformed runtimes before all external boundaries`, () => { + for (const version of [ + '24.18.0', + '20.19.0', + '21.9.0', + '23.0.0', + '22.18.9', + 'private-invalid-value', + ]) { + const stderr = guardedChild([mode], version); + assert.match(stderr, /RETENTION_STOP: runtime-mismatch/); + assert.ok(!stderr.includes(version)); + } + }); + + test(`${mode} accepts supported runtime with mocked provider and unchanged source checks`, async () => { + const world = standardWorld(); + const lines = []; + await withRetentionRuntime(() => + main([mode], makeDeps(world), { log: (line) => lines.push(line) }), + ); + const summary = JSON.parse( + lines.find((line) => line.startsWith('RETENTION_SUMMARY ')).slice(18), + ); + assert.equal(summary.stop, null); + assert.equal(summary.writeAttempts, mode === 'dry-run' ? 0 : world.writes.length); + assert.equal(world.released, mode === 'dry-run' ? 0 : 1); + assert.ok(world.calls.some((call) => call.startsWith('vercel api'))); + }); +} + +test('the actual matrix runtime refuses before tools, locks and runner imports', () => { + const supported = /^22\./.test(process.versions.node); + const argv = supported ? ['--execute'] : ['reconcile']; + const stderr = guardedChild(argv); + assert.ok( + stderr.includes(`RETENTION_STOP: ${supported ? 'invalid-arguments' : 'runtime-mismatch'}`), + ); +}); + +test('supported runtime still rejects a modified source before provider calls or locks', async () => { + const world = standardWorld(); + world.differs = true; + await assert.rejects( + withRetentionRuntime(() => main(['reconcile'], makeDeps(world), { log() {} })), + { reason: 'runner-source-differs-from-main' }, + ); + assert.ok(world.calls.every((call) => call.startsWith('git '))); + assert.deepEqual(world.writes, []); + assert.equal(world.released, 0); +}); diff --git a/tests/unit/tooling/vercelRetentionSafety.test.mjs b/tests/unit/tooling/vercelRetentionSafety.test.mjs index 86e5db0ed..339168064 100644 --- a/tests/unit/tooling/vercelRetentionSafety.test.mjs +++ b/tests/unit/tooling/vercelRetentionSafety.test.mjs @@ -14,6 +14,7 @@ import { preview, production, standardWorld, + withRetentionRuntime, } from './support/vercelRetentionFixture.mjs'; const OTHER = 'c'.repeat(40); @@ -466,7 +467,9 @@ test('reconcile prints the whole manifest before its first write, then one summa world.hooks.onWrite = () => { seenAtFirstWrite ??= [...lines]; }; - await main(['reconcile'], makeDeps(world), { log: (line) => lines.push(line) }); + await withRetentionRuntime(() => + main(['reconcile'], makeDeps(world), { log: (line) => lines.push(line) }), + ); assert.equal(seenAtFirstWrite.length, 8); assert.ok(seenAtFirstWrite.every((line) => line.startsWith('RETENTION_MANIFEST '))); assert.equal(lines.filter((line) => line.startsWith('RETENTION_SUMMARY')).length, 1); @@ -475,7 +478,9 @@ test('reconcile prints the whole manifest before its first write, then one summa test('a manifest row shows the state, target, age and alias hosts the gates looked at', async () => { const lines = []; - await main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) }); + await withRetentionRuntime(() => + main(['dry-run'], makeDeps(standardWorld()), { log: (line) => lines.push(line) }), + ); const row = lines.find((line) => line.includes('dpl_prev12 | SAFE')); assert.match(row, /\| READY\/preview \| 1970-01-01T00:00:01\.012Z \|/); assert.match(row, /aliases=worldscript-studio-git-fix-gone-qnbs-projects\.vercel\.app/); From 3027553d8476df217478549d188ce029b2c839ac Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:54:06 +0200 Subject: [PATCH 2/3] docs(changelog): record retention runtime guard --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2f3a37b..ac166309e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Maintenance:** Require the Vercel retention operator to run Node 22.19.0 or later in the 22.x line before source checks or provider/child-process access; the application's Node 22/24 CI matrix is unchanged. Issue #627. PR #1078. + - **Dependencies:** Update Tokio from 1.53.1 to 1.53.2 in the Tauri lockfile (PR #1076; Dependabot source PR #1044). No manifest or source changes. - **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no handle that opens the file can write to it while the removal holds it, with the exception of a writable mapped view that existed before, see below; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`, and a directory is looked at again right before the disposition so that one converted into a junction meanwhile is left alone (`Redirected`; narrows the window, does not close it). A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed); on Unix, which has no sharing mode, any same-user writer can change the bytes between the authentication and the removal, which was not stated before: the removal binds the object, not its bytes. No new package. Issue #359. PR #1075. From 19dfd0223ecae4da8b35c8932e1ee92ad33bd614 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:03:40 +0200 Subject: [PATCH 3/3] test(retention): tighten runtime and reconcile assertions --- .../tooling/vercelRetentionRuntime.test.mjs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/unit/tooling/vercelRetentionRuntime.test.mjs b/tests/unit/tooling/vercelRetentionRuntime.test.mjs index 08d5db90b..ccaa8d1c4 100644 --- a/tests/unit/tooling/vercelRetentionRuntime.test.mjs +++ b/tests/unit/tooling/vercelRetentionRuntime.test.mjs @@ -102,16 +102,27 @@ for (const mode of ['dry-run', 'reconcile']) { assert.equal(summary.writeAttempts, mode === 'dry-run' ? 0 : world.writes.length); assert.equal(world.released, mode === 'dry-run' ? 0 : 1); assert.ok(world.calls.some((call) => call.startsWith('vercel api'))); + if (mode === 'reconcile') { + const expectedDeleted = ['dpl_prev12', 'dpl_prev11', 'dpl_prev2', 'dpl_prev1']; + const deploymentWrites = world.writes + .filter((write) => write.startsWith('remove dpl_')) + .map((write) => write.split(' ')[1]); + assert.ok(summary.writeAttempts > 0); + assert.deepEqual(summary.deleted, expectedDeleted); + assert.deepEqual(deploymentWrites, expectedDeleted); + } }); } test('the actual matrix runtime refuses before tools, locks and runner imports', () => { - const supported = /^22\./.test(process.versions.node); - const argv = supported ? ['--execute'] : ['reconcile']; - const stderr = guardedChild(argv); - assert.ok( - stderr.includes(`RETENTION_STOP: ${supported ? 'invalid-arguments' : 'runtime-mismatch'}`), - ); + let expectedStop = 'invalid-arguments'; + try { + assertRuntime(); + } catch (error) { + expectedStop = error.reason; + } + const stderr = guardedChild(['--execute']); + assert.ok(stderr.includes(`RETENTION_STOP: ${expectedStop}`)); }); test('supported runtime still rejects a modified source before provider calls or locks', async () => {