diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 6fe78588d0c..428196d010d 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -107,6 +107,8 @@ jobs: node --check config/scripts/ssh-relay-runtime-reproducibility.test.mjs node --check config/scripts/build-ssh-relay-runtime.mjs node --check config/scripts/ssh-relay-runtime-build.test.mjs + node --check config/scripts/ssh-relay-runtime-closure.mjs + node --check config/scripts/ssh-relay-runtime-closure.test.mjs pnpm exec vitest run --config config/vitest.config.ts \ config/scripts/ssh-relay-node-release-verification.test.mjs \ config/scripts/ssh-relay-node-tar-inspection.test.mjs \ @@ -116,6 +118,7 @@ jobs: config/scripts/ssh-relay-node-zip-inspection.test.mjs \ config/scripts/ssh-relay-runtime-artifact.test.mjs \ config/scripts/ssh-relay-runtime-build.test.mjs \ + config/scripts/ssh-relay-runtime-closure.test.mjs \ config/scripts/ssh-relay-runtime-pty-smoke.test.mjs \ config/scripts/ssh-relay-runtime-reproducibility.test.mjs \ config/scripts/ssh-relay-runtime-resource-diagnostics.test.mjs \ @@ -279,6 +282,10 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'runtime build script syntax check failed' } node --check config/scripts/ssh-relay-runtime-build.test.mjs if ($LASTEXITCODE -ne 0) { throw 'runtime build test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-closure.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime closure script syntax check failed' } + node --check config/scripts/ssh-relay-runtime-closure.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime closure test syntax check failed' } pnpm exec vitest run --config config/vitest.config.ts ` config/scripts/ssh-relay-node-release-verification.test.mjs ` config/scripts/ssh-relay-node-tar-inspection.test.mjs ` @@ -288,6 +295,7 @@ jobs: config/scripts/ssh-relay-node-zip-inspection.test.mjs ` config/scripts/ssh-relay-runtime-artifact.test.mjs ` config/scripts/ssh-relay-runtime-build.test.mjs ` + config/scripts/ssh-relay-runtime-closure.test.mjs ` config/scripts/ssh-relay-runtime-pty-smoke.test.mjs ` config/scripts/ssh-relay-runtime-reproducibility.test.mjs ` config/scripts/ssh-relay-runtime-resource-diagnostics.test.mjs ` diff --git a/config/scripts/ssh-relay-runtime-closure.mjs b/config/scripts/ssh-relay-runtime-closure.mjs new file mode 100644 index 00000000000..b6dfa9aa5a6 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-closure.mjs @@ -0,0 +1,252 @@ +import { readFile } from 'node:fs/promises' +import { isDeepStrictEqual } from 'node:util' +import { join } from 'node:path' + +const NODE_VERSION = '24.18.0' +const NODE_PTY_VERSION = '1.1.0' +const PARCEL_WATCHER_VERSION = '2.5.6' + +const WATCHER_PACKAGES = Object.freeze({ + 'linux-x64-glibc': '@parcel/watcher-linux-x64-glibc', + 'linux-arm64-glibc': '@parcel/watcher-linux-arm64-glibc', + 'darwin-x64': '@parcel/watcher-darwin-x64', + 'darwin-arm64': '@parcel/watcher-darwin-arm64', + 'win32-x64': '@parcel/watcher-win32-x64', + 'win32-arm64': '@parcel/watcher-win32-arm64' +}) + +const COMMON_FILES = Object.freeze([ + '.version', + 'THIRD_PARTY_LICENSES.txt', + 'node_modules/@parcel/watcher/index.js', + 'node_modules/@parcel/watcher/package.json', + 'node_modules/@parcel/watcher/wrapper.js', + 'node_modules/detect-libc/lib/detect-libc.js', + 'node_modules/detect-libc/lib/elf.js', + 'node_modules/detect-libc/lib/filesystem.js', + 'node_modules/detect-libc/lib/process.js', + 'node_modules/detect-libc/package.json', + 'node_modules/is-extglob/index.js', + 'node_modules/is-extglob/package.json', + 'node_modules/is-glob/index.js', + 'node_modules/is-glob/package.json', + 'node_modules/node-pty/lib/eventEmitter2.js', + 'node_modules/node-pty/lib/index.js', + 'node_modules/node-pty/lib/terminal.js', + 'node_modules/node-pty/lib/utils.js', + 'node_modules/node-pty/package.json', + 'node_modules/picomatch/index.js', + 'node_modules/picomatch/lib/constants.js', + 'node_modules/picomatch/lib/parse.js', + 'node_modules/picomatch/lib/picomatch.js', + 'node_modules/picomatch/lib/scan.js', + 'node_modules/picomatch/lib/utils.js', + 'node_modules/picomatch/package.json', + 'relay-watcher.js', + 'relay.js', + 'runtime-metadata.json' +]) + +const POSIX_NODE_PTY_FILES = Object.freeze([ + 'node_modules/node-pty/build/Release/pty.node', + 'node_modules/node-pty/lib/unixTerminal.js' +]) + +const WINDOWS_NODE_PTY_FILES = Object.freeze([ + 'node_modules/node-pty/build/Release/conpty.node', + 'node_modules/node-pty/build/Release/conpty/OpenConsole.exe', + 'node_modules/node-pty/build/Release/conpty/conpty.dll', + 'node_modules/node-pty/build/Release/conpty_console_list.node', + 'node_modules/node-pty/lib/conpty_console_list_agent.js', + 'node_modules/node-pty/lib/shared/conout.js', + 'node_modules/node-pty/lib/windowsConoutConnection.js', + 'node_modules/node-pty/lib/windowsPtyAgent.js', + 'node_modules/node-pty/lib/windowsTerminal.js', + 'node_modules/node-pty/lib/worker/conoutSocketWorker.js' +]) + +export function sshRelayRuntimeWatcherPackage(tuple) { + const packageName = WATCHER_PACKAGES[tuple] + if (!packageName) { + throw new Error(`No bundled Parcel watcher package is defined for ${tuple}`) + } + return packageName +} + +export function sshRelayRuntimeFileRole(path) { + if (path === 'bin/node' || path === 'bin/node.exe') { + return 'node' + } + if (path === 'relay.js') { + return 'relay' + } + if (path === 'relay-watcher.js') { + return 'relay-watcher' + } + if (/\/(?:pty|conpty|conpty_console_list)\.node$/.test(path)) { + return 'node-pty-native' + } + if (path.endsWith('/watcher.node')) { + return 'parcel-watcher-native' + } + if ( + path.endsWith('/spawn-helper') || + path.endsWith('/conpty/conpty.dll') || + path.endsWith('/conpty/OpenConsole.exe') + ) { + return 'native-runtime' + } + if (path === 'THIRD_PARTY_LICENSES.txt') { + return 'license' + } + return 'runtime-javascript' +} + +function expectedFiles(tuple) { + const watcherPackage = sshRelayRuntimeWatcherPackage(tuple) + const paths = [ + ...COMMON_FILES, + tuple.startsWith('win32-') ? 'bin/node.exe' : 'bin/node', + `node_modules/${watcherPackage}/package.json`, + `node_modules/${watcherPackage}/watcher.node`, + ...(tuple.startsWith('win32-') ? WINDOWS_NODE_PTY_FILES : POSIX_NODE_PTY_FILES) + ] + if (tuple.startsWith('darwin-')) { + paths.push('node_modules/node-pty/build/Release/spawn-helper') + } + return paths.sort().map((path) => { + const role = sshRelayRuntimeFileRole(path) + const mode = ['node', 'node-pty-native', 'parcel-watcher-native', 'native-runtime'].includes( + role + ) + ? 0o755 + : 0o644 + return { path, type: 'file', role, mode } + }) +} + +function expectedDirectories(files) { + const paths = new Set() + for (const file of files) { + const segments = file.path.split('/') + for (let depth = 1; depth < segments.length; depth += 1) { + paths.add(segments.slice(0, depth).join('/')) + } + } + return [...paths].sort().map((path) => ({ path, type: 'directory', mode: 0o755 })) +} + +export function expectedSshRelayRuntimeClosureEntries(tuple) { + const files = expectedFiles(tuple) + return [...expectedDirectories(files), ...files] +} + +function indexEntries(entries, type) { + const indexed = new Map() + for (const entry of entries.filter((candidate) => candidate.type === type)) { + if (indexed.has(entry.path)) { + throw new Error(`Runtime closure contains a duplicate ${type}: ${entry.path}`) + } + indexed.set(entry.path, entry) + } + return indexed +} + +function assertExactEntries(actualEntries, expectedEntries, type) { + const actual = indexEntries(actualEntries, type) + const expected = indexEntries(expectedEntries, type) + for (const [path, contract] of expected) { + const entry = actual.get(path) + if (!entry) { + throw new Error(`Runtime closure is missing required ${type}: ${path}`) + } + for (const field of type === 'file' ? ['role', 'mode'] : ['mode']) { + if (entry[field] !== contract[field]) { + throw new Error(`Runtime closure ${type} has unexpected ${field}: ${path}`) + } + } + actual.delete(path) + } + if (actual.size > 0) { + throw new Error(`Runtime closure contains undeclared ${type}: ${actual.keys().next().value}`) + } +} + +export function assertSshRelayRuntimeClosureEntries(identity) { + if ( + identity.nodeVersion !== NODE_VERSION || + identity.dependencies?.nodePtyVersion !== NODE_PTY_VERSION || + identity.dependencies?.parcelWatcherVersion !== PARCEL_WATCHER_VERSION + ) { + // Why: dependency refreshes require a reviewed desktop release and a new immutable manifest. + throw new Error('Runtime closure dependency versions do not match the reviewed contract') + } + const expected = expectedSshRelayRuntimeClosureEntries(identity.tupleId) + assertExactEntries(identity.entries, expected, 'directory') + assertExactEntries(identity.entries, expected, 'file') +} + +function expectedPackages(tuple) { + const watcherPackage = sshRelayRuntimeWatcherPackage(tuple) + return [ + { name: 'node-pty', version: NODE_PTY_VERSION, main: './lib/index.js', license: 'MIT' }, + { name: '@parcel/watcher', version: PARCEL_WATCHER_VERSION, main: 'index.js', license: 'MIT' }, + { name: watcherPackage, version: PARCEL_WATCHER_VERSION, main: 'watcher.node', license: 'MIT' }, + { name: 'detect-libc', version: '2.1.2', main: 'lib/detect-libc.js', license: 'Apache-2.0' }, + { name: 'is-glob', version: '4.0.3', main: 'index.js', license: 'MIT' }, + { name: 'is-extglob', version: '2.1.1', main: 'index.js', license: 'MIT' }, + { name: 'picomatch', version: '4.0.4', main: 'index.js', license: 'MIT' } + ] +} + +function assertLicenseSections(text, packageNames) { + const matches = [...text.matchAll(/^===== ([^\r\n]+) =====$/gm)] + const names = matches.map((match) => match[1]) + if (!isDeepStrictEqual(names, ['Node.js', ...packageNames])) { + throw new Error('Runtime license bundle does not match the exact package closure') + } + for (let index = 0; index < matches.length; index += 1) { + const bodyStart = matches[index].index + matches[index][0].length + const bodyEnd = matches[index + 1]?.index ?? text.length + if (text.slice(bodyStart, bodyEnd).trim().length === 0) { + throw new Error(`Runtime license bundle has an empty section: ${matches[index][1]}`) + } + } +} + +export async function verifySshRelayRuntimeClosure(runtimeRoot, identity) { + assertSshRelayRuntimeClosureEntries(identity) + const packages = expectedPackages(identity.tupleId) + for (const expected of packages) { + const path = join(runtimeRoot, 'node_modules', ...expected.name.split('/'), 'package.json') + const actual = JSON.parse(await readFile(path, 'utf8')) + if (!isDeepStrictEqual(actual, expected)) { + throw new Error( + `Runtime package metadata does not match the closure contract: ${expected.name}` + ) + } + } + const [licenseText, versionText, metadataText] = await Promise.all([ + readFile(join(runtimeRoot, 'THIRD_PARTY_LICENSES.txt'), 'utf8'), + readFile(join(runtimeRoot, '.version'), 'utf8'), + readFile(join(runtimeRoot, 'runtime-metadata.json'), 'utf8') + ]) + assertLicenseSections( + licenseText, + packages.map((entry) => entry.name) + ) + const metadata = JSON.parse(metadataText) + if ( + metadata.schemaVersion !== 1 || + metadata.tuple !== identity.tupleId || + metadata.nodeVersion !== identity.nodeVersion || + metadata.relayBuildVersion !== versionText.trim() || + !isDeepStrictEqual(metadata.dependencies, { + nodePtyVersion: NODE_PTY_VERSION, + parcelWatcherVersion: PARCEL_WATCHER_VERSION + }) + ) { + throw new Error('Runtime metadata does not match the exact package closure') + } + return { files: identity.entries.filter((entry) => entry.type === 'file').length, packages: 7 } +} diff --git a/config/scripts/ssh-relay-runtime-closure.test.mjs b/config/scripts/ssh-relay-runtime-closure.test.mjs new file mode 100644 index 00000000000..d107a2da4c7 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-closure.test.mjs @@ -0,0 +1,179 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + assertSshRelayRuntimeClosureEntries, + expectedSshRelayRuntimeClosureEntries, + verifySshRelayRuntimeClosure +} from './ssh-relay-runtime-closure.mjs' + +const temporaryDirectories = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))) +}) + +const watcherPackages = Object.freeze({ + 'linux-x64-glibc': '@parcel/watcher-linux-x64-glibc', + 'linux-arm64-glibc': '@parcel/watcher-linux-arm64-glibc', + 'darwin-x64': '@parcel/watcher-darwin-x64', + 'darwin-arm64': '@parcel/watcher-darwin-arm64', + 'win32-x64': '@parcel/watcher-win32-x64', + 'win32-arm64': '@parcel/watcher-win32-arm64' +}) + +function identity(tuple) { + return { + tupleId: tuple, + nodeVersion: '24.18.0', + dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' }, + entries: expectedSshRelayRuntimeClosureEntries(tuple) + } +} + +function packageRecords(tuple) { + return [ + { name: 'node-pty', version: '1.1.0', main: './lib/index.js', license: 'MIT' }, + { name: '@parcel/watcher', version: '2.5.6', main: 'index.js', license: 'MIT' }, + { name: watcherPackages[tuple], version: '2.5.6', main: 'watcher.node', license: 'MIT' }, + { name: 'detect-libc', version: '2.1.2', main: 'lib/detect-libc.js', license: 'Apache-2.0' }, + { name: 'is-glob', version: '4.0.3', main: 'index.js', license: 'MIT' }, + { name: 'is-extglob', version: '2.1.1', main: 'index.js', license: 'MIT' }, + { name: 'picomatch', version: '4.0.4', main: 'index.js', license: 'MIT' } + ] +} + +async function writeClosureMetadata(tuple = 'linux-x64-glibc') { + const runtimeRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-closure-')) + temporaryDirectories.push(runtimeRoot) + const packages = packageRecords(tuple) + for (const record of packages) { + const path = join(runtimeRoot, 'node_modules', ...record.name.split('/'), 'package.json') + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify(record, null, 2)}\n`) + } + await writeFile(join(runtimeRoot, '.version'), '1.4.140+123456789abc\n') + await writeFile( + join(runtimeRoot, 'runtime-metadata.json'), + `${JSON.stringify( + { + schemaVersion: 1, + tuple, + nodeVersion: '24.18.0', + relayBuildVersion: '1.4.140+123456789abc', + dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' } + }, + null, + 2 + )}\n` + ) + const licenseNames = ['Node.js', ...packages.map((entry) => entry.name)] + await writeFile( + join(runtimeRoot, 'THIRD_PARTY_LICENSES.txt'), + `${licenseNames.map((name) => `===== ${name} =====\n${name} license text\n`).join('\n')}\n` + ) + return { runtimeRoot, packages } +} + +describe('SSH relay runtime exact closure', () => { + it.each([ + ['linux-x64-glibc', 34], + ['linux-arm64-glibc', 34], + ['darwin-x64', 35], + ['darwin-arm64', 35], + ['win32-x64', 42], + ['win32-arm64', 42] + ])('pins the reviewed %s runtime to %i files and one native watcher', (tuple, fileCount) => { + const entries = expectedSshRelayRuntimeClosureEntries(tuple) + const files = entries.filter((entry) => entry.type === 'file') + expect(files).toHaveLength(fileCount) + expect(files.filter((entry) => entry.role === 'parcel-watcher-native')).toEqual([ + expect.objectContaining({ path: `node_modules/${watcherPackages[tuple]}/watcher.node` }) + ]) + expect(files.map((entry) => entry.path)).not.toEqual( + expect.arrayContaining([ + expect.stringMatching(/(?:^|\/)(?:npm|npx|corepack|pnpm|yarn)(?:\.|\/|$)/i), + expect.stringMatching(/\.(?:map|pdb|ilk|cc|cpp|c|h|hpp|o|obj)$/i) + ]) + ) + expect(() => assertSshRelayRuntimeClosureEntries(identity(tuple))).not.toThrow() + }) + + it('rejects undeclared package managers, source maps, and native build outputs', () => { + const fixture = identity('linux-x64-glibc') + for (const path of [ + 'node_modules/npm/bin/npm-cli.js', + 'relay.js.map', + 'node_modules/node-pty/build/Release/pty.pdb' + ]) { + expect(() => + assertSshRelayRuntimeClosureEntries({ + ...fixture, + entries: [ + ...fixture.entries, + { path, type: 'file', role: 'runtime-javascript', mode: 0o644 } + ] + }) + ).toThrow(/undeclared file/i) + } + }) + + it('rejects a missing native dependency, wrong role, or unreviewed dependency refresh', () => { + const fixture = identity('darwin-arm64') + expect(() => + assertSshRelayRuntimeClosureEntries({ + ...fixture, + entries: fixture.entries.filter((entry) => !entry.path.endsWith('/watcher.node')) + }) + ).toThrow(/missing required file/i) + expect(() => + assertSshRelayRuntimeClosureEntries({ + ...fixture, + entries: fixture.entries.map((entry) => + entry.path === 'bin/node' ? { ...entry, role: 'runtime-javascript' } : entry + ) + }) + ).toThrow(/unexpected role/i) + expect(() => + assertSshRelayRuntimeClosureEntries({ ...fixture, nodeVersion: '24.19.0' }) + ).toThrow(/dependency versions/i) + }) + + it('verifies exact package metadata, runtime metadata, and non-empty license sections', async () => { + const fixture = await writeClosureMetadata() + await expect( + verifySshRelayRuntimeClosure(fixture.runtimeRoot, identity('linux-x64-glibc')) + ).resolves.toEqual({ files: 34, packages: 7 }) + + const packagePath = join(fixture.runtimeRoot, 'node_modules', 'node-pty', 'package.json') + const packageJson = JSON.parse(await readFile(packagePath, 'utf8')) + await writeFile(packagePath, `${JSON.stringify({ ...packageJson, version: '1.1.1' })}\n`) + await expect( + verifySshRelayRuntimeClosure(fixture.runtimeRoot, identity('linux-x64-glibc')) + ).rejects.toThrow(/node-pty/i) + }) + + it('rejects omitted and empty dependency license sections', async () => { + const fixture = await writeClosureMetadata() + const licensePath = join(fixture.runtimeRoot, 'THIRD_PARTY_LICENSES.txt') + const licenseText = await readFile(licensePath, 'utf8') + await writeFile(licensePath, licenseText.replace(/===== picomatch =====[\s\S]*$/, '')) + await expect( + verifySshRelayRuntimeClosure(fixture.runtimeRoot, identity('linux-x64-glibc')) + ).rejects.toThrow(/license bundle/i) + + const second = await writeClosureMetadata() + const secondLicensePath = join(second.runtimeRoot, 'THIRD_PARTY_LICENSES.txt') + const secondText = await readFile(secondLicensePath, 'utf8') + await writeFile( + secondLicensePath, + secondText.replace('===== picomatch =====\npicomatch license text', '===== picomatch =====') + ) + await expect( + verifySshRelayRuntimeClosure(second.runtimeRoot, identity('linux-x64-glibc')) + ).rejects.toThrow(/empty section/i) + }) +}) diff --git a/config/scripts/ssh-relay-runtime-tree.mjs b/config/scripts/ssh-relay-runtime-tree.mjs index e524e3ea199..ffe15e33f05 100644 --- a/config/scripts/ssh-relay-runtime-tree.mjs +++ b/config/scripts/ssh-relay-runtime-tree.mjs @@ -4,6 +4,11 @@ import { createRequire } from 'node:module' import { dirname, join, relative, sep } from 'node:path' import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity.mjs' +import { + sshRelayRuntimeFileRole, + sshRelayRuntimeWatcherPackage, + verifySshRelayRuntimeClosure +} from './ssh-relay-runtime-closure.mjs' const require = createRequire(import.meta.url) const MAX_FILES = 5_000 @@ -53,15 +58,6 @@ const COMPATIBILITY = Object.freeze({ } }) -const WATCHER_PACKAGES = Object.freeze({ - 'linux-x64-glibc': '@parcel/watcher-linux-x64-glibc', - 'linux-arm64-glibc': '@parcel/watcher-linux-arm64-glibc', - 'darwin-x64': '@parcel/watcher-darwin-x64', - 'darwin-arm64': '@parcel/watcher-darwin-arm64', - 'win32-x64': '@parcel/watcher-win32-x64', - 'win32-arm64': '@parcel/watcher-win32-arm64' -}) - function packageDirectory(name) { return dirname(require.resolve(`${name}/package.json`)) } @@ -176,10 +172,7 @@ async function copyPackageRuntime(name, runtimeRoot, relativePaths) { } async function copyParcelWatcher(runtimeRoot, tuple) { - const watcherPackage = WATCHER_PACKAGES[tuple] - if (!watcherPackage) { - throw new Error(`No bundled Parcel watcher package is defined for ${tuple}`) - } + const watcherPackage = sshRelayRuntimeWatcherPackage(tuple) await copyPackageRuntime('@parcel/watcher', runtimeRoot, ['index.js', 'wrapper.js']) await copyPackageRuntime(watcherPackage, runtimeRoot, ['watcher.node']) await chmod( @@ -221,39 +214,6 @@ async function writeLicenses(runtimeRoot, nodeRoot, packages) { await chmod(join(runtimeRoot, 'THIRD_PARTY_LICENSES.txt'), 0o644) } -function runtimeRole(path) { - if (path === 'bin/node' || path === 'bin/node.exe') { - return 'node' - } - if (path === 'relay.js') { - return 'relay' - } - if (path === 'relay-watcher.js') { - return 'relay-watcher' - } - if ( - path.endsWith('/pty.node') || - path.endsWith('/conpty.node') || - path.endsWith('/conpty_console_list.node') - ) { - return 'node-pty-native' - } - if (path.endsWith('/watcher.node')) { - return 'parcel-watcher-native' - } - if ( - path.endsWith('/spawn-helper') || - path.endsWith('/conpty/conpty.dll') || - path.endsWith('/conpty/OpenConsole.exe') - ) { - return 'native-runtime' - } - if (path === 'THIRD_PARTY_LICENSES.txt') { - return 'license' - } - return 'runtime-javascript' -} - function assertPortableRuntimePath(path) { const segments = path.split('/') if ( @@ -301,7 +261,7 @@ async function collectEntries(runtimeRoot) { if (metadata.size > MAX_FILE_BYTES) { throw new Error(`Runtime file exceeds size limit: ${path}`) } - const role = runtimeRole(path) + const role = sshRelayRuntimeFileRole(path) // Why: NTFS has no POSIX execute bits; ZIP metadata carries the canonical runtime mode. const mode = process.platform === 'win32' && @@ -392,5 +352,6 @@ export async function assembleSshRelayRuntimeTree({ dependencies: { nodePtyVersion: '1.1.0', parcelWatcherVersion: '2.5.6' }, entries: tree.entries } + await verifySshRelayRuntimeClosure(runtimeRoot, identity) return { ...identity, contentId: computeSshRelayRuntimeContentId(identity), ...tree } } diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 553a01217e1..1815d229a8e 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -61,6 +61,10 @@ describe('SSH relay runtime artifact workflow', () => { expect(source).toContain('foreach ($output in @($firstOutput, $secondOutput))') expect(source).toContain('ssh-relay-runtime-reproducibility.mjs') expect(source).toContain('ssh-relay-runtime-reproducibility.test.mjs') + expect(source.match(/ssh-relay-runtime-closure\.test\.mjs/g)).toHaveLength(4) + expect( + source.match(/node --check config\/scripts\/ssh-relay-runtime-closure\.mjs/g) + ).toHaveLength(2) expect(source).toContain('ssh-relay-runtime-windows-pe-diagnostic.mjs') expect(source).toContain('ssh-relay-runtime-windows-pe-diagnostic.test.mjs') expect(source).toContain('llvm-objdump.exe')