From 360fd1adea7d2fe5c603aaba5100ec07ec7013eb Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sat, 29 Aug 2026 07:15:01 +0900 Subject: [PATCH 01/10] feat: validate num_modules/byte_count for esm-chunked candidates Add runtime validation of num_modules and byte_count from the discoverModuleGraph result against audited values to detect potential binary corruption or version mismatches early in the native package validation flow. Co-Authored-By: Claude Haiku 4.5 --- packages/claude-code/lib/prepare-native.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/claude-code/lib/prepare-native.js b/packages/claude-code/lib/prepare-native.js index 82028fe..91dfd74 100755 --- a/packages/claude-code/lib/prepare-native.js +++ b/packages/claude-code/lib/prepare-native.js @@ -165,6 +165,12 @@ function validateEsmChunkedOffsets(file, audited) { if (graph.entryName !== '/$bunfs/root/cli') { throw new Error(`esm-chunked entry module name mismatch for ${version}: ${graph.entryName}`); } + if (audited.num_modules !== undefined && graph.numModules !== audited.num_modules) { + throw new Error(`esm-chunked num_modules mismatch for ${version}: expected ${audited.num_modules}, got ${graph.numModules}`); + } + if (audited.byte_count !== undefined && graph.byteCount !== audited.byte_count) { + throw new Error(`esm-chunked byte_count mismatch for ${version}: expected ${audited.byte_count}, got ${graph.byteCount}`); + } const prefix = readEntryContentPrefix(graph.fd, graph.entryModule, 256).toString('utf8'); const codeStart = prefix.replace(/^(\s*\/\/[^\n]*\n)+/, '').replace(/^\(/, ''); if (codeStart.startsWith('function(exports, require, module, __filename, __dirname) {')) { From f3678e85929976230f1ee33a2c2e3cc73f9d30e0 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sat, 29 Aug 2026 07:24:05 +0900 Subject: [PATCH 02/10] test: add unit tests for num_modules/byte_count validation - Extract validateEsmChunkedOffsets, validateLegacyCjsOffsets, validateOffsets, and verifyTarball to native-validators.js - Parameterize version argument to enable testability - Add native-validators.test.js with 3 test cases covering normal and error cases - All existing tests (bunfs-extract.test.js: 11 tests) continue to pass Co-Authored-By: Claude Sonnet 5 --- packages/claude-code/lib/native-validators.js | 87 ++++++++++ .../claude-code/lib/native-validators.test.js | 157 ++++++++++++++++++ packages/claude-code/lib/prepare-native.js | 80 +-------- 3 files changed, 249 insertions(+), 75 deletions(-) create mode 100644 packages/claude-code/lib/native-validators.js create mode 100644 packages/claude-code/lib/native-validators.test.js diff --git a/packages/claude-code/lib/native-validators.js b/packages/claude-code/lib/native-validators.js new file mode 100644 index 0000000..16272a5 --- /dev/null +++ b/packages/claude-code/lib/native-validators.js @@ -0,0 +1,87 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const packageDir = path.resolve(__dirname, '..'); + +function verifyTarball(file, audited, version) { + const buf = fs.readFileSync(file); + if (audited.tarball_sha256) { + const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); + if (sha256 !== audited.tarball_sha256) { + throw new Error(`tarball sha256 mismatch for ${version}`); + } + } + if (audited.tarball_integrity) { + const sha512 = `sha512-${crypto.createHash('sha512').update(buf).digest('base64')}`; + if (sha512 !== audited.tarball_integrity) { + throw new Error(`tarball integrity mismatch for ${version}`); + } + } + if (audited.tarball_size !== undefined && Number(audited.tarball_size) !== buf.length) { + throw new Error(`tarball size mismatch for ${version}`); + } +} + +function validateOffsets(file, audited, version) { + if (audited.entry_format === 'esm-chunked') { + validateEsmChunkedOffsets(file, audited, version); + return; + } + validateLegacyCjsOffsets(file, audited, version); +} + +function validateLegacyCjsOffsets(file, audited, version) { + const buf = fs.readFileSync(file); + const start = Number(audited.entry_js_offset); + const end = Number(audited.entry_end_offset); + const startMarker = Buffer.from('function(exports, require, module, __filename, __dirname) {// Claude Code is a Beta product'); + const endMarker = Buffer.from('/$bunfs/root/image-processor.js'); + + if (!(start > 0 && end > start && end <= buf.length)) { + throw new Error(`invalid audited offsets for ${version}`); + } + if (!buf.subarray(start, start + startMarker.length).equals(startMarker)) { + throw new Error(`audited start offset validation failed for ${version}`); + } + if (!buf.subarray(end, end + endMarker.length).equals(endMarker)) { + throw new Error(`audited end offset validation failed for ${version}`); + } +} + +function validateEsmChunkedOffsets(file, audited, version) { + // 371MB超のバイナリ全体をreadFileSyncしない(実機でOOM確認済み)。 + // discoverModuleGraphは範囲readSyncのみでトレイラー・モジュールテーブルを検証する。 + const { discoverModuleGraph, readEntryContentPrefix } = require(path.join(packageDir, 'lib', 'bunfs-extract.js')); + const graph = discoverModuleGraph(file); + try { + if (!(graph.numModules > 0)) { + throw new Error(`esm-chunked module graph is empty for ${version}`); + } + if (graph.entryName !== '/$bunfs/root/cli') { + throw new Error(`esm-chunked entry module name mismatch for ${version}: ${graph.entryName}`); + } + if (audited.num_modules !== undefined && graph.numModules !== audited.num_modules) { + throw new Error(`esm-chunked num_modules mismatch for ${version}: expected ${audited.num_modules}, got ${graph.numModules}`); + } + if (audited.byte_count !== undefined && graph.byteCount !== audited.byte_count) { + throw new Error(`esm-chunked byte_count mismatch for ${version}: expected ${audited.byte_count}, got ${graph.byteCount}`); + } + const prefix = readEntryContentPrefix(graph.fd, graph.entryModule, 256).toString('utf8'); + const codeStart = prefix.replace(/^(\s*\/\/[^\n]*\n)+/, '').replace(/^\(/, ''); + if (codeStart.startsWith('function(exports, require, module, __filename, __dirname) {')) { + throw new Error(`entry module for ${version} is legacy-cjs wrapped, but audited entry_format is esm-chunked`); + } + } finally { + fs.closeSync(graph.fd); + } +} + +module.exports = { + validateEsmChunkedOffsets, + validateLegacyCjsOffsets, + validateOffsets, + verifyTarball, +}; diff --git a/packages/claude-code/lib/native-validators.test.js b/packages/claude-code/lib/native-validators.test.js new file mode 100644 index 0000000..032d44a --- /dev/null +++ b/packages/claude-code/lib/native-validators.test.js @@ -0,0 +1,157 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + validateEsmChunkedOffsets, +} = require('./native-validators.js'); + +const { + discoverModuleGraph, +} = require('./bunfs-extract.js'); + +const TRAILER = '\n---- Bun! ----\n'; + +// StandaloneModuleGraphの最小合成バイナリを構築する。 +// レイアウト: [preamble padding][module contents][module table][Offsets(32byte)][trailer] +function buildSyntheticBinary({ modules, entryPointId, corruptTrailer = false, preamblePadding = 64 }) { + const nameBuffers = modules.map((m) => Buffer.from(m.name, 'utf8')); + const contentBuffers = modules.map((m) => Buffer.from(m.content ?? '', 'utf8')); + + const dataParts = []; + const nameOffsets = []; + const contOffsets = []; + let cursor = 0; + for (let i = 0; i < modules.length; i += 1) { + nameOffsets.push(cursor); + dataParts.push(nameBuffers[i]); + cursor += nameBuffers[i].length; + } + for (let i = 0; i < modules.length; i += 1) { + contOffsets.push(cursor); + dataParts.push(contentBuffers[i]); + cursor += contentBuffers[i].length; + } + const byteCountBeforeTable = cursor; + + const MODULE_TABLE_ENTRY_SIZE = 52; + const modTable = Buffer.alloc(MODULE_TABLE_ENTRY_SIZE * modules.length); + for (let i = 0; i < modules.length; i += 1) { + const base = i * MODULE_TABLE_ENTRY_SIZE; + modTable.writeUInt32LE(nameOffsets[i], base); + modTable.writeUInt32LE(nameBuffers[i].length, base + 4); + modTable.writeUInt32LE(contOffsets[i], base + 8); + modTable.writeUInt32LE(contentBuffers[i].length, base + 12); + modTable[base + 49] = modules[i].loader ?? 1; // 1 = js + } + const modulesOffset = byteCountBeforeTable; + const modulesLength = modTable.length; + const byteCount = byteCountBeforeTable + modulesLength; + + const offsetsBuf = Buffer.alloc(32); + offsetsBuf.writeBigUInt64LE(BigInt(byteCount), 0); + offsetsBuf.writeUInt32LE(modulesOffset, 8); + offsetsBuf.writeUInt32LE(modulesLength, 12); + offsetsBuf.writeUInt32LE(entryPointId, 16); + + const trailerBuf = Buffer.from(corruptTrailer ? '\n---- NOT BUN ----\n' : TRAILER, 'utf8'); + + return Buffer.concat([ + Buffer.alloc(preamblePadding), + ...dataParts, + modTable, + offsetsBuf, + trailerBuf, + ]); +} + +function writeTempBinary(buf) { + const file = path.join(os.tmpdir(), `native-validators-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bin`); + fs.writeFileSync(file, buf); + return file; +} + +test('validateEsmChunkedOffsets accepts matching num_modules and byte_count', () => { + const buf = buildSyntheticBinary({ + modules: [ + { name: '/$bunfs/root/cli', content: 'export default 1;' }, + ], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + try { + const graph = discoverModuleGraph(file); + try { + const audited = { + num_modules: graph.numModules, + byte_count: graph.byteCount, + entry_format: 'esm-chunked', + }; + assert.doesNotThrow(() => validateEsmChunkedOffsets(file, audited, '9.9.9')); + } finally { + fs.closeSync(graph.fd); + } + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test('validateEsmChunkedOffsets rejects mismatched num_modules', () => { + const buf = buildSyntheticBinary({ + modules: [ + { name: '/$bunfs/root/cli', content: 'export default 1;' }, + ], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + try { + const graph = discoverModuleGraph(file); + try { + const audited = { + num_modules: graph.numModules + 1, // intentionally wrong + byte_count: graph.byteCount, + entry_format: 'esm-chunked', + }; + assert.throws( + () => validateEsmChunkedOffsets(file, audited, '9.9.9'), + /num_modules mismatch/, + ); + } finally { + fs.closeSync(graph.fd); + } + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test('validateEsmChunkedOffsets rejects mismatched byte_count', () => { + const buf = buildSyntheticBinary({ + modules: [ + { name: '/$bunfs/root/cli', content: 'export default 1;' }, + ], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + try { + const graph = discoverModuleGraph(file); + try { + const audited = { + num_modules: graph.numModules, + byte_count: graph.byteCount + 1, // intentionally wrong + entry_format: 'esm-chunked', + }; + assert.throws( + () => validateEsmChunkedOffsets(file, audited, '9.9.9'), + /byte_count mismatch/, + ); + } finally { + fs.closeSync(graph.fd); + } + } finally { + fs.rmSync(file, { force: true }); + } +}); diff --git a/packages/claude-code/lib/prepare-native.js b/packages/claude-code/lib/prepare-native.js index 91dfd74..0be6988 100755 --- a/packages/claude-code/lib/prepare-native.js +++ b/packages/claude-code/lib/prepare-native.js @@ -16,6 +16,8 @@ const curlRetries = process.env.CLAUDE_TERMUX_FETCH_RETRIES || '4'; const curlConnectTimeout = process.env.CLAUDE_TERMUX_FETCH_CONNECT_TIMEOUT || '20'; const curlMaxTime = process.env.CLAUDE_TERMUX_FETCH_MAX_TIME || '300'; +const { validateOffsets, verifyTarball } = require(path.join(packageDir, 'lib', 'native-validators.js')); + if (!item) { console.error(`Unsupported audited Claude Code version: ${version}`); process.exit(1); @@ -27,7 +29,7 @@ const nativeDest = path.join(versionDir, 'app', 'node_modules', '@anthropic-ai', const sourceBin = path.join(nativeDest, 'claude'); if (fs.existsSync(sourceBin)) { - validateOffsets(sourceBin, item); + validateOffsets(sourceBin, item, version); process.exit(0); } @@ -36,7 +38,7 @@ const packDir = fs.mkdtempSync(path.join(os.tmpdir(), `claude-code-${version}-`) try { const tgzPath = fetchNativeTarball(item.native_spec, packDir); - verifyTarball(tgzPath, item); + verifyTarball(tgzPath, item, version); const extractDir = path.join(packDir, 'native'); fs.mkdirSync(extractDir, { recursive: true }); @@ -45,7 +47,7 @@ try { fs.rmSync(nativeDest, { recursive: true, force: true }); fs.mkdirSync(path.dirname(nativeDest), { recursive: true }); fs.cpSync(path.join(extractDir, 'package'), nativeDest, { recursive: true }); - validateOffsets(sourceBin, item); + validateOffsets(sourceBin, item, version); } finally { fs.rmSync(packDir, { recursive: true, force: true }); } @@ -108,75 +110,3 @@ function fetchNativeTarball(spec, packDir) { return path.join(packDir, packEntry.filename); } -function verifyTarball(file, audited) { - const buf = fs.readFileSync(file); - if (audited.tarball_sha256) { - const sha256 = crypto.createHash('sha256').update(buf).digest('hex'); - if (sha256 !== audited.tarball_sha256) { - throw new Error(`tarball sha256 mismatch for ${version}`); - } - } - if (audited.tarball_integrity) { - const sha512 = `sha512-${crypto.createHash('sha512').update(buf).digest('base64')}`; - if (sha512 !== audited.tarball_integrity) { - throw new Error(`tarball integrity mismatch for ${version}`); - } - } - if (audited.tarball_size !== undefined && Number(audited.tarball_size) !== buf.length) { - throw new Error(`tarball size mismatch for ${version}`); - } -} - -function validateOffsets(file, audited) { - if (audited.entry_format === 'esm-chunked') { - validateEsmChunkedOffsets(file, audited); - return; - } - validateLegacyCjsOffsets(file, audited); -} - -function validateLegacyCjsOffsets(file, audited) { - const buf = fs.readFileSync(file); - const start = Number(audited.entry_js_offset); - const end = Number(audited.entry_end_offset); - const startMarker = Buffer.from('function(exports, require, module, __filename, __dirname) {// Claude Code is a Beta product'); - const endMarker = Buffer.from('/$bunfs/root/image-processor.js'); - - if (!(start > 0 && end > start && end <= buf.length)) { - throw new Error(`invalid audited offsets for ${version}`); - } - if (!buf.subarray(start, start + startMarker.length).equals(startMarker)) { - throw new Error(`audited start offset validation failed for ${version}`); - } - if (!buf.subarray(end, end + endMarker.length).equals(endMarker)) { - throw new Error(`audited end offset validation failed for ${version}`); - } -} - -function validateEsmChunkedOffsets(file, audited) { - // 371MB超のバイナリ全体をreadFileSyncしない(実機でOOM確認済み)。 - // discoverModuleGraphは範囲readSyncのみでトレイラー・モジュールテーブルを検証する。 - const { discoverModuleGraph, readEntryContentPrefix } = require(path.join(packageDir, 'lib', 'bunfs-extract.js')); - const graph = discoverModuleGraph(file); - try { - if (!(graph.numModules > 0)) { - throw new Error(`esm-chunked module graph is empty for ${version}`); - } - if (graph.entryName !== '/$bunfs/root/cli') { - throw new Error(`esm-chunked entry module name mismatch for ${version}: ${graph.entryName}`); - } - if (audited.num_modules !== undefined && graph.numModules !== audited.num_modules) { - throw new Error(`esm-chunked num_modules mismatch for ${version}: expected ${audited.num_modules}, got ${graph.numModules}`); - } - if (audited.byte_count !== undefined && graph.byteCount !== audited.byte_count) { - throw new Error(`esm-chunked byte_count mismatch for ${version}: expected ${audited.byte_count}, got ${graph.byteCount}`); - } - const prefix = readEntryContentPrefix(graph.fd, graph.entryModule, 256).toString('utf8'); - const codeStart = prefix.replace(/^(\s*\/\/[^\n]*\n)+/, '').replace(/^\(/, ''); - if (codeStart.startsWith('function(exports, require, module, __filename, __dirname) {')) { - throw new Error(`entry module for ${version} is legacy-cjs wrapped, but audited entry_format is esm-chunked`); - } - } finally { - fs.closeSync(graph.fd); - } -} From dac578683219a2cded17628031733e84d686d349 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sat, 29 Aug 2026 07:30:51 +0900 Subject: [PATCH 03/10] chore: remove unused crypto import and trailing whitespace Co-Authored-By: Claude Sonnet 5 --- packages/claude-code/lib/prepare-native.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/claude-code/lib/prepare-native.js b/packages/claude-code/lib/prepare-native.js index 0be6988..7a3a5ba 100755 --- a/packages/claude-code/lib/prepare-native.js +++ b/packages/claude-code/lib/prepare-native.js @@ -2,7 +2,6 @@ 'use strict'; const cp = require('child_process'); -const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -109,4 +108,3 @@ function fetchNativeTarball(spec, packDir) { } return path.join(packDir, packEntry.filename); } - From ccf4a374af59f7096901aea751b66a6aa24d7ac8 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sat, 29 Aug 2026 09:47:21 +0900 Subject: [PATCH 04/10] fix: resolve bunfs virtual paths in import.meta.require polyfill - Consolidate 3 separate integration tests into 1 unified test to avoid register() hook accumulation issues - Test validates: normal /$bunfs/root/ resolution, path traversal rejection, and missing module errors - Add bunfs-esm-loader.test.js and native-validators.test.js to CI Co-Authored-By: Claude Sonnet 5 --- .github/workflows/npm-package.yml | 2 + packages/claude-code/lib/bunfs-esm-loader.mjs | 17 ++++++ .../claude-code/lib/bunfs-esm-loader.test.js | 60 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/.github/workflows/npm-package.yml b/.github/workflows/npm-package.yml index 8e4e518..8bb60ea 100644 --- a/.github/workflows/npm-package.yml +++ b/.github/workflows/npm-package.yml @@ -86,6 +86,8 @@ jobs: node --check ../../scripts/retag-latest-dist-tags.js node --test ../../scripts/retag-latest-dist-tags.test.js node --test lib/termux-run-claude-native.test.js + node --test lib/bunfs-esm-loader.test.js + node --test lib/native-validators.test.js - name: Verify audited metadata run: | diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index 1552dd8..919d131 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -22,10 +22,27 @@ function buildImportMetaRequirePolyfillPrelude(anchorUrl) { `import __bunfsGuardedChildProcess from ${JSON.stringify(pathToFileURL(CHILD_PROCESS_GUARD_PATH).href)};\n` + `import __bunfsGuardedVm from ${JSON.stringify(pathToFileURL(VM_GUARD_PATH).href)};\n` + `import { createRequire as __bunfsCreateRequire } from "node:module";\n` + + `import __bunfsMetaRequirePath from "node:path";\n` + + `import { existsSync as __bunfsMetaRequireExistsSync } from "node:fs";\n` + `const __bunfsRealRequire = __bunfsCreateRequire(${JSON.stringify(anchorUrl)});\n` + + `const __bunfsOwnedDir = ${JSON.stringify(PROCESS_OWNED_DIR)};\n` + `const __bunfsMetaRequire = (id) => {\n` + ` if (id === "child_process" || id === "node:child_process") return __bunfsGuardedChildProcess;\n` + ` if (id === "vm" || id === "node:vm") return __bunfsGuardedVm;\n` + + ` if (typeof id === "string" && id.startsWith("/$bunfs/root/")) {\n` + + ` const rel = id.slice("/$bunfs/root/".length);\n` + + ` if (rel.includes("..") || __bunfsMetaRequirePath.isAbsolute(rel)) {\n` + + ` throw new Error("bunfs meta-require: rejected specifier " + id);\n` + + ` }\n` + + ` const real = __bunfsMetaRequirePath.resolve(__bunfsOwnedDir, rel);\n` + + ` if (__bunfsMetaRequirePath.relative(__bunfsOwnedDir, real).startsWith("..")) {\n` + + ` throw new Error("bunfs meta-require: path escapes process-owned dir: " + id);\n` + + ` }\n` + + ` if (!__bunfsMetaRequireExistsSync(real)) {\n` + + ` throw new Error("bunfs meta-require: missing extracted module " + id + " -> " + real);\n` + + ` }\n` + + ` return __bunfsRealRequire(real);\n` + + ` }\n` + ` return __bunfsRealRequire(id);\n` + `};\n` ); diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index b1a28c1..ec2676c 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -327,3 +327,63 @@ test('load() calls nextLoad for URLs outside processOwnedDir', async () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +test('import.meta.require resolves /$bunfs/root/ specifiers via loader integration', async () => { + const { register } = await import('node:module'); + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-integration-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + // Create guard files + fs.writeFileSync(path.join(tempDir, 'guard.mjs'), 'export default {};'); + fs.writeFileSync(path.join(tempDir, 'vm-guard.mjs'), 'export default {};'); + fs.writeFileSync(path.join(tempDir, 'ws-stub.mjs'), 'export default {};'); + + // Create CommonJS fixture that will be required (normal case) + fs.writeFileSync(path.join(tempDir, 'foo.js'), 'module.exports = { value: 42 };'); + + // Create ESM files for each test scenario + const okCallerPath = path.join(tempDir, 'ok-caller.mjs'); + fs.writeFileSync(okCallerPath, 'export const result = import.meta.require("/$bunfs/root/foo.js").value;\n'); + + const traversalCallerPath = path.join(tempDir, 'traversal-caller.mjs'); + fs.writeFileSync(traversalCallerPath, 'import.meta.require("/$bunfs/root/../../etc/passwd");\n'); + + const missingCallerPath = path.join(tempDir, 'missing-caller.mjs'); + fs.writeFileSync(missingCallerPath, 'import.meta.require("/$bunfs/root/nonexistent.js");\n'); + + // Register loader (only once) with data + const sourceBin = path.join(tempDir, 'dummy-bin'); + fs.writeFileSync(sourceBin, '#!/bin/false'); + + register(pathToFileURL(path.join(__dirname, 'bunfs-esm-loader.mjs')).href, { + parentURL: pathToFileURL(__filename).href, + data: { + processOwnedDir: tempDir, + sourceBin: sourceBin, + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + }, + }); + + // Test 1: Normal case - should load and resolve correctly + const okModule = await import(pathToFileURL(okCallerPath).href); + assert.equal(okModule.result, 42); + + // Test 2: Path traversal rejection - should throw error + await assert.rejects( + () => import(pathToFileURL(traversalCallerPath).href), + /rejected specifier|escapes/, + ); + + // Test 3: Missing module rejection - should throw error + await assert.rejects( + () => import(pathToFileURL(missingCallerPath).href), + /missing extracted module/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); From aecaabe708604c3f8c33e48937cea2f1b8ca9b77 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sat, 29 Aug 2026 12:15:42 +0900 Subject: [PATCH 05/10] test: reproduce and verify fix for ERR_REQUIRE_CYCLE_MODULE in bunfs loader --- packages/claude-code/lib/bunfs-esm-loader.mjs | 36 +++++++++- .../claude-code/lib/bunfs-esm-loader.test.js | 65 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index 919d131..2a430ed 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -17,6 +17,29 @@ export function initialize(data) { WS_STUB_PATH = data.wsStubPath; } +const CYCLE_HOIST_TARGET_FILE = 'chunk-vmw9kxhv.js'; +const CYCLE_HOIST_TARGET_DECL = 'var O9=import.meta.require("/$bunfs/root/chunk-y0jj307t.js")'; +const CYCLE_HOIST_TARGET_MODULE = 'chunk-y0jj307t.js'; +const CYCLE_HOIST_REPLACEMENT = 'var O9=__bunfsHoisted_0'; + +function tryHoistCycleBreakingImport(filePath, source) { + const rel = path.relative(PROCESS_OWNED_DIR, filePath); + if (rel !== CYCLE_HOIST_TARGET_FILE) return null; + + // 出現数が厳密に1件でなければ変換しない(fail-closed) + const occurrences = source.split(CYCLE_HOIST_TARGET_DECL).length - 1; + if (occurrences !== 1) return null; + + // 注入先の存在確認(resolve()と同じtraversalガードを流用) + const real = path.resolve(PROCESS_OWNED_DIR, CYCLE_HOIST_TARGET_MODULE); + if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) return null; + if (!existsSync(real)) return null; + + const hoistedImportLine = `import * as __bunfsHoisted_0 from ${JSON.stringify(pathToFileURL(real).href)};\n`; + const newSource = source.replace(CYCLE_HOIST_TARGET_DECL, CYCLE_HOIST_REPLACEMENT); + return { hoistedImportLine, source: newSource }; +} + function buildImportMetaRequirePolyfillPrelude(anchorUrl) { return ( `import __bunfsGuardedChildProcess from ${JSON.stringify(pathToFileURL(CHILD_PROCESS_GUARD_PATH).href)};\n` + @@ -82,10 +105,21 @@ export async function load(url, context, nextLoad) { } const filePath = fileURLToPath(url); let source = readFileSync(filePath, 'utf8'); + + let hoistedImportLine = ''; + const hoistResult = tryHoistCycleBreakingImport(filePath, source); + if (hoistResult) { + hoistedImportLine = hoistResult.hoistedImportLine; + source = hoistResult.source; + } + if (source.includes('import.meta.require')) { const anchorUrl = pathToFileURL(SOURCE_BIN).href; - source = buildImportMetaRequirePolyfillPrelude(anchorUrl) + + source = hoistedImportLine + + buildImportMetaRequirePolyfillPrelude(anchorUrl) + source.replaceAll('import.meta.require', '__bunfsMetaRequire'); + } else if (hoistedImportLine) { + source = hoistedImportLine + source; } return { format: 'module', source, shortCircuit: true }; } diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index ec2676c..79b0142 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -328,6 +328,38 @@ test('load() calls nextLoad for URLs outside processOwnedDir', async () => { } }); +test('load() hoists the cycle-breaking import.meta.require call in chunk-vmw9kxhv.js', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-hoist-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + fs.writeFileSync(path.join(tempDir, 'chunk-y0jj307t.js'), 'export const daemonColdStartGbDefault = () => "fixture";\n'); + const targetFile = path.join(tempDir, 'chunk-vmw9kxhv.js'); + const sourceCode = 'var O9=import.meta.require("/$bunfs/root/chunk-y0jj307t.js");\nexport const value = O9.daemonColdStartGbDefault();\n'; + fs.writeFileSync(targetFile, sourceCode); + + try { + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: '/dummy/bin', + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + }); + + const fileUrl = pathToFileURL(targetFile).href; + const result = await loader.load(fileUrl, {}, async () => ({ source: 'fallback' })); + + assert.equal(result.format, 'module'); + assert.ok(result.source.includes('import * as __bunfsHoisted_0 from')); + assert.ok(!result.source.includes('var O9=import.meta.require(')); + assert.ok(result.source.includes('var O9=__bunfsHoisted_0')); + assert.equal(result.shortCircuit, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('import.meta.require resolves /$bunfs/root/ specifiers via loader integration', async () => { const { register } = await import('node:module'); const loader = await import('./bunfs-esm-loader.mjs'); @@ -383,6 +415,39 @@ test('import.meta.require resolves /$bunfs/root/ specifiers via loader integrati () => import(pathToFileURL(missingCallerPath).href), /missing extracted module/, ); + + // Cycle regression proof: a generic sync-require-into-in-flight-static-import cycle + // must throw ERR_REQUIRE_CYCLE_MODULE when NOT hoisted (proves our understanding of + // the bug mechanism is correct, independent of the real chunk-y0jj307t.js file). + fs.writeFileSync( + path.join(tempDir, 'chunk-cycle-demo-target.js'), + 'import "/$bunfs/root/chunk-vmw9kxhv-a.js";\nexport const daemonColdStartGbDefault = () => "fixture";\n', + ); + fs.writeFileSync( + path.join(tempDir, 'chunk-vmw9kxhv-a.js'), + 'var O9X=import.meta.require("/$bunfs/root/chunk-cycle-demo-target.js");\nexport const value = O9X;\n', + ); + await assert.rejects( + () => import(pathToFileURL(path.join(tempDir, 'chunk-vmw9kxhv-a.js')).href), + (err) => { + assert.equal(err.code, 'ERR_REQUIRE_CYCLE_MODULE'); + return true; + }, + ); + + // Cycle fix proof: the real chunk-vmw9kxhv.js / chunk-y0jj307t.js pair (exact filenames + // and declaration text that tryHoistCycleBreakingImport() targets) must resolve cleanly + // once hoisting is applied, and the hoisted namespace's property access must work. + fs.writeFileSync( + path.join(tempDir, 'chunk-y0jj307t.js'), + 'import "/$bunfs/root/chunk-vmw9kxhv.js";\nexport const daemonColdStartGbDefault = () => "fixture";\n', + ); + fs.writeFileSync( + path.join(tempDir, 'chunk-vmw9kxhv.js'), + 'var O9=import.meta.require("/$bunfs/root/chunk-y0jj307t.js");\nexport const value = O9.daemonColdStartGbDefault();\n', + ); + const hoistedModule = await import(pathToFileURL(path.join(tempDir, 'chunk-vmw9kxhv.js')).href); + assert.equal(hoistedModule.value, 'fixture'); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } From cdd9f88c249157af32e56df904f640759aab5085 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sat, 29 Aug 2026 14:08:40 +0900 Subject: [PATCH 06/10] fix: return text content for .md/.txt bunfs assets in import.meta.require --- packages/claude-code/lib/bunfs-esm-loader.mjs | 5 ++++ .../claude-code/lib/bunfs-esm-loader.test.js | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index 2a430ed..731ae71 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -47,6 +47,7 @@ function buildImportMetaRequirePolyfillPrelude(anchorUrl) { `import { createRequire as __bunfsCreateRequire } from "node:module";\n` + `import __bunfsMetaRequirePath from "node:path";\n` + `import { existsSync as __bunfsMetaRequireExistsSync } from "node:fs";\n` + + `import { readFileSync as __bunfsMetaRequireReadFileSync } from "node:fs";\n` + `const __bunfsRealRequire = __bunfsCreateRequire(${JSON.stringify(anchorUrl)});\n` + `const __bunfsOwnedDir = ${JSON.stringify(PROCESS_OWNED_DIR)};\n` + `const __bunfsMetaRequire = (id) => {\n` + @@ -64,6 +65,10 @@ function buildImportMetaRequirePolyfillPrelude(anchorUrl) { ` if (!__bunfsMetaRequireExistsSync(real)) {\n` + ` throw new Error("bunfs meta-require: missing extracted module " + id + " -> " + real);\n` + ` }\n` + + ` const ext = __bunfsMetaRequirePath.extname(real);\n` + + ` if (ext === ".md" || ext === ".txt") {\n` + + ` return __bunfsMetaRequireReadFileSync(real, "utf8");\n` + + ` }\n` + ` return __bunfsRealRequire(real);\n` + ` }\n` + ` return __bunfsRealRequire(id);\n` + diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index 79b0142..a8a1c7c 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -448,6 +448,35 @@ test('import.meta.require resolves /$bunfs/root/ specifiers via loader integrati ); const hoistedModule = await import(pathToFileURL(path.join(tempDir, 'chunk-vmw9kxhv.js')).href); assert.equal(hoistedModule.value, 'fixture'); + + fs.writeFileSync(path.join(tempDir, 'doc.md'), '# Hello\nSome markdown text.\n'); + fs.writeFileSync( + path.join(tempDir, 'md-caller.mjs'), + 'export const result = import.meta.require("/$bunfs/root/doc.md");\n', + ); + const mdModule = await import(pathToFileURL(path.join(tempDir, 'md-caller.mjs')).href); + assert.equal(typeof mdModule.result, 'string'); + assert.equal(mdModule.result, '# Hello\nSome markdown text.\n'); + + fs.writeFileSync(path.join(tempDir, 'note.txt'), 'plain text content'); + fs.writeFileSync( + path.join(tempDir, 'txt-caller.mjs'), + 'export const result = import.meta.require("/$bunfs/root/note.txt");\n', + ); + const txtModule = await import(pathToFileURL(path.join(tempDir, 'txt-caller.mjs')).href); + assert.equal(typeof txtModule.result, 'string'); + assert.equal(txtModule.result, 'plain text content'); + + fs.writeFileSync( + path.join(tempDir, 'chunk-alias.js'), + 'export const ee = import.meta.require;\n', + ); + fs.writeFileSync( + path.join(tempDir, 'alias-caller.mjs'), + 'import { ee } from "/$bunfs/root/chunk-alias.js";\nexport const result = ee("/$bunfs/root/doc.md");\n', + ); + const aliasModule = await import(pathToFileURL(path.join(tempDir, 'alias-caller.mjs')).href); + assert.equal(aliasModule.result, '# Hello\nSome markdown text.\n'); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } From 6c353910bf476d7b2448c4338c19e1e4eb6d1b4c Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sat, 29 Aug 2026 22:01:17 +0900 Subject: [PATCH 07/10] feat: replace hardcoded O9 cycle fix with data-driven cycle_hoists design - Add analyzeCycleHoists and discoverCycleHoists functions to audit cycle-breaking patterns - Modify discoverOffsets to load cycle_hoists data from binary analysis - Implement data-driven hoisting in bunfs-esm-loader.mjs to support multiple cycles - Add __bunfsAssertHoistedProps helper to detect evaluation-order regressions - Update cycle_hoists field in both config JSON files for versions 2.1.245 and 2.1.248 - Add comprehensive test coverage for cycle hoisting logic - Integrate cycle_hoists into termux-run-claude-native.sh register() calls This change moves from a hardcoded single-cycle fix (O9/chunk-y0jj307t.js) to a generic, data-driven design that can handle arbitrary module graph cycles, improving maintainability and reducing brittleness for future versions. Co-Authored-By: Claude Sonnet 5 --- config/claude-native-audited-versions.json | 14 +- .../claude-native-audited-versions.json | 14 +- packages/claude-code/lib/bunfs-esm-loader.mjs | 67 ++++-- .../claude-code/lib/bunfs-esm-loader.test.js | 4 + packages/claude-code/lib/native-validators.js | 3 + .../claude-code/lib/native-validators.test.js | 3 + .../lib/termux-run-claude-native.sh | 24 ++ scripts/add-candidate-metadata.js | 4 + .../termux-prepare-claude-native-version.js | 213 +++++++++++++++++- ...rmux-prepare-claude-native-version.test.js | 127 +++++++++++ 10 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 scripts/termux-prepare-claude-native-version.test.js diff --git a/config/claude-native-audited-versions.json b/config/claude-native-audited-versions.json index 57b1349..3989a47 100644 --- a/config/claude-native-audited-versions.json +++ b/config/claude-native-audited-versions.json @@ -777,6 +777,7 @@ "entry_format": "esm-chunked", "tarball_integrity": "sha512-Qbn5HnZbYeW4GdifVkDGfcVKqj3/f3U9sfVd3LEaUZh08CcS8D/ptJ76zuBfQUGHJHfToAdNVfak86uJ84b1Dg==", "tarball_sha256": "668662e7b5d91a93cff6c75736e60f3d5d3bed4cbe077ae4feefc63ad1253f4d", + "cycle_hoists": [], "status": "termux_verified" }, "2.1.248": { @@ -787,7 +788,18 @@ "tarball_sha256": "bd2e2b6ebace34039b073b0f586d521ec09c895fa2a39de8d1c86423ae5884ca", "status": "offset_discovered", "num_modules": 1946, - "byte_count": 136124175 + "byte_count": 136124175, + "cycle_hoists": [ + { "file": "chunk-vmw9kxhv.js", "targetModule": "chunk-y0jj307t.js", "expectedOccurrences": 1, "assertProperties": [] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-ppd18hq1.js", "expectedOccurrences": 1, "assertProperties": ["MonitorTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-830s9480.js", "expectedOccurrences": 1, "assertProperties": ["EndConversationTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-m4hx5z9g.js", "expectedOccurrences": 1, "assertProperties": ["ArtifactTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-eg121wbt.js", "expectedOccurrences": 1, "assertProperties": ["WorkflowTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-prrm838s.js", "expectedOccurrences": 1, "assertProperties": [] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-exvwsc2a.js", "expectedOccurrences": 2, "assertProperties": [] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-s61yn21a.js", "expectedOccurrences": 1, "assertProperties": ["default"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-040m9a9s.js", "expectedOccurrences": 3, "assertProperties": ["getWorkflowCommands", "invalidateWorkflowCache", "warmWorkflows"] } + ] } } } diff --git a/packages/claude-code/config/claude-native-audited-versions.json b/packages/claude-code/config/claude-native-audited-versions.json index 57b1349..3989a47 100644 --- a/packages/claude-code/config/claude-native-audited-versions.json +++ b/packages/claude-code/config/claude-native-audited-versions.json @@ -777,6 +777,7 @@ "entry_format": "esm-chunked", "tarball_integrity": "sha512-Qbn5HnZbYeW4GdifVkDGfcVKqj3/f3U9sfVd3LEaUZh08CcS8D/ptJ76zuBfQUGHJHfToAdNVfak86uJ84b1Dg==", "tarball_sha256": "668662e7b5d91a93cff6c75736e60f3d5d3bed4cbe077ae4feefc63ad1253f4d", + "cycle_hoists": [], "status": "termux_verified" }, "2.1.248": { @@ -787,7 +788,18 @@ "tarball_sha256": "bd2e2b6ebace34039b073b0f586d521ec09c895fa2a39de8d1c86423ae5884ca", "status": "offset_discovered", "num_modules": 1946, - "byte_count": 136124175 + "byte_count": 136124175, + "cycle_hoists": [ + { "file": "chunk-vmw9kxhv.js", "targetModule": "chunk-y0jj307t.js", "expectedOccurrences": 1, "assertProperties": [] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-ppd18hq1.js", "expectedOccurrences": 1, "assertProperties": ["MonitorTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-830s9480.js", "expectedOccurrences": 1, "assertProperties": ["EndConversationTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-m4hx5z9g.js", "expectedOccurrences": 1, "assertProperties": ["ArtifactTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-eg121wbt.js", "expectedOccurrences": 1, "assertProperties": ["WorkflowTool"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-prrm838s.js", "expectedOccurrences": 1, "assertProperties": [] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-exvwsc2a.js", "expectedOccurrences": 2, "assertProperties": [] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-s61yn21a.js", "expectedOccurrences": 1, "assertProperties": ["default"] }, + { "file": "chunk-9n7t2tbt.js", "targetModule": "chunk-040m9a9s.js", "expectedOccurrences": 3, "assertProperties": ["getWorkflowCommands", "invalidateWorkflowCache", "warmWorkflows"] } + ] } } } diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index 731ae71..aa0169f 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -9,35 +9,50 @@ let CHILD_PROCESS_GUARD_PATH = null; let VM_GUARD_PATH = null; let WS_STUB_PATH = null; +let CYCLE_HOISTS = []; + export function initialize(data) { PROCESS_OWNED_DIR = data.processOwnedDir; SOURCE_BIN = data.sourceBin; CHILD_PROCESS_GUARD_PATH = data.childProcessGuardPath; VM_GUARD_PATH = data.vmGuardPath; WS_STUB_PATH = data.wsStubPath; + CYCLE_HOISTS = Array.isArray(data.cycleHoists) ? data.cycleHoists : []; } -const CYCLE_HOIST_TARGET_FILE = 'chunk-vmw9kxhv.js'; -const CYCLE_HOIST_TARGET_DECL = 'var O9=import.meta.require("/$bunfs/root/chunk-y0jj307t.js")'; -const CYCLE_HOIST_TARGET_MODULE = 'chunk-y0jj307t.js'; -const CYCLE_HOIST_REPLACEMENT = 'var O9=__bunfsHoisted_0'; - -function tryHoistCycleBreakingImport(filePath, source) { +function tryHoistCycleBreakingImports(filePath, source) { const rel = path.relative(PROCESS_OWNED_DIR, filePath); - if (rel !== CYCLE_HOIST_TARGET_FILE) return null; + const records = CYCLE_HOISTS.filter((r) => r.file === rel); + if (records.length === 0) return null; + + let hoistedImportLines = ''; + let newSource = source; + let varIndex = 0; + const targetToVar = new Map(); - // 出現数が厳密に1件でなければ変換しない(fail-closed) - const occurrences = source.split(CYCLE_HOIST_TARGET_DECL).length - 1; - if (occurrences !== 1) return null; + for (const record of records) { + const literal = `import.meta.require("/$bunfs/root/${record.targetModule}")`; + const occurrences = newSource.split(literal).length - 1; + if (occurrences !== record.expectedOccurrences) continue; - // 注入先の存在確認(resolve()と同じtraversalガードを流用) - const real = path.resolve(PROCESS_OWNED_DIR, CYCLE_HOIST_TARGET_MODULE); - if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) return null; - if (!existsSync(real)) return null; + const real = path.resolve(PROCESS_OWNED_DIR, record.targetModule); + if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) continue; + if (!existsSync(real)) continue; - const hoistedImportLine = `import * as __bunfsHoisted_0 from ${JSON.stringify(pathToFileURL(real).href)};\n`; - const newSource = source.replace(CYCLE_HOIST_TARGET_DECL, CYCLE_HOIST_REPLACEMENT); - return { hoistedImportLine, source: newSource }; + let varName = targetToVar.get(record.targetModule); + if (!varName) { + varName = `__bunfsHoisted_${varIndex++}`; + targetToVar.set(record.targetModule, varName); + hoistedImportLines += `import * as ${varName} from ${JSON.stringify(pathToFileURL(real).href)};\n`; + if (record.assertProperties && record.assertProperties.length > 0) { + hoistedImportLines += `__bunfsAssertHoistedProps(${varName}, ${JSON.stringify(record.targetModule)}, ${JSON.stringify(record.assertProperties)});\n`; + } + } + newSource = newSource.replaceAll(literal, varName); + } + + if (!targetToVar.size) return null; + return { hoistedImportLine: hoistedImportLines, source: newSource }; } function buildImportMetaRequirePolyfillPrelude(anchorUrl) { @@ -48,6 +63,22 @@ function buildImportMetaRequirePolyfillPrelude(anchorUrl) { `import __bunfsMetaRequirePath from "node:path";\n` + `import { existsSync as __bunfsMetaRequireExistsSync } from "node:fs";\n` + `import { readFileSync as __bunfsMetaRequireReadFileSync } from "node:fs";\n` + + `function __bunfsAssertHoistedProps(ns, targetModule, propNames) {\n` + + ` for (const p of propNames) {\n` + + ` let v;\n` + + ` try {\n` + + ` v = ns[p];\n` + + ` } catch (e) {\n` + + ` if (e instanceof ReferenceError) {\n` + + ` throw new Error("bunfs cycle-hoist: accessing \\"" + p + "\\" on " + targetModule + " threw ReferenceError (TDZ) - evaluation-order regression");\n` + + ` }\n` + + ` throw e;\n` + + ` }\n` + + ` if (v === undefined) {\n` + + ` throw new Error("bunfs cycle-hoist: \\"" + p + "\\" is undefined after hoisting from " + targetModule + " (evaluation-order regression?)");\n` + + ` }\n` + + ` }\n` + + `}\n` + `const __bunfsRealRequire = __bunfsCreateRequire(${JSON.stringify(anchorUrl)});\n` + `const __bunfsOwnedDir = ${JSON.stringify(PROCESS_OWNED_DIR)};\n` + `const __bunfsMetaRequire = (id) => {\n` + @@ -112,7 +143,7 @@ export async function load(url, context, nextLoad) { let source = readFileSync(filePath, 'utf8'); let hoistedImportLine = ''; - const hoistResult = tryHoistCycleBreakingImport(filePath, source); + const hoistResult = tryHoistCycleBreakingImports(filePath, source); if (hoistResult) { hoistedImportLine = hoistResult.hoistedImportLine; source = hoistResult.source; diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index a8a1c7c..cdc7a2c 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -345,6 +345,7 @@ test('load() hoists the cycle-breaking import.meta.require call in chunk-vmw9kxh childProcessGuardPath: path.join(tempDir, 'guard.mjs'), vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + cycleHoists: [{ file: 'chunk-vmw9kxhv.js', targetModule: 'chunk-y0jj307t.js', expectedOccurrences: 1, assertProperties: [] }], }); const fileUrl = pathToFileURL(targetFile).href; @@ -397,6 +398,9 @@ test('import.meta.require resolves /$bunfs/root/ specifiers via loader integrati childProcessGuardPath: path.join(tempDir, 'guard.mjs'), vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + cycleHoists: [ + { file: 'chunk-vmw9kxhv.js', targetModule: 'chunk-y0jj307t.js', expectedOccurrences: 1, assertProperties: [] }, + ], }, }); diff --git a/packages/claude-code/lib/native-validators.js b/packages/claude-code/lib/native-validators.js index 16272a5..208142e 100644 --- a/packages/claude-code/lib/native-validators.js +++ b/packages/claude-code/lib/native-validators.js @@ -63,6 +63,9 @@ function validateEsmChunkedOffsets(file, audited, version) { if (graph.entryName !== '/$bunfs/root/cli') { throw new Error(`esm-chunked entry module name mismatch for ${version}: ${graph.entryName}`); } + if (!Object.prototype.hasOwnProperty.call(audited, 'cycle_hoists')) { + throw new Error(`esm-chunked audited metadata for ${version} is missing cycle_hoists field`); + } if (audited.num_modules !== undefined && graph.numModules !== audited.num_modules) { throw new Error(`esm-chunked num_modules mismatch for ${version}: expected ${audited.num_modules}, got ${graph.numModules}`); } diff --git a/packages/claude-code/lib/native-validators.test.js b/packages/claude-code/lib/native-validators.test.js index 032d44a..8c0d649 100644 --- a/packages/claude-code/lib/native-validators.test.js +++ b/packages/claude-code/lib/native-validators.test.js @@ -90,6 +90,7 @@ test('validateEsmChunkedOffsets accepts matching num_modules and byte_count', () num_modules: graph.numModules, byte_count: graph.byteCount, entry_format: 'esm-chunked', + cycle_hoists: [], }; assert.doesNotThrow(() => validateEsmChunkedOffsets(file, audited, '9.9.9')); } finally { @@ -115,6 +116,7 @@ test('validateEsmChunkedOffsets rejects mismatched num_modules', () => { num_modules: graph.numModules + 1, // intentionally wrong byte_count: graph.byteCount, entry_format: 'esm-chunked', + cycle_hoists: [], }; assert.throws( () => validateEsmChunkedOffsets(file, audited, '9.9.9'), @@ -143,6 +145,7 @@ test('validateEsmChunkedOffsets rejects mismatched byte_count', () => { num_modules: graph.numModules, byte_count: graph.byteCount + 1, // intentionally wrong entry_format: 'esm-chunked', + cycle_hoists: [], }; assert.throws( () => validateEsmChunkedOffsets(file, audited, '9.9.9'), diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index 08941fb..de40f00 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -690,6 +690,17 @@ async function esmChunkedMain() { globalThis.__claudeBunShim = globalThis.Bun; globalThis.__claudeBun = globalThis.Bun; + let cycleHoists = []; + try { + const auditedVersions = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'config', 'claude-native-audited-versions.json')); + const entry = auditedVersions.versions?.[process.env.CURRENT_CLAUDE_VERSION]; + if (entry && Array.isArray(entry.cycle_hoists)) { + cycleHoists = entry.cycle_hoists; + } + } catch { + // 読み込み失敗時は空配列のまま(fail-closed、既存の同期require経路にフォールバック) + } + register(pathToFileURL(path.join(libDir, 'bunfs-esm-loader.mjs')).href, { parentURL: pathToFileURL(__filename).href, data: { @@ -698,6 +709,7 @@ async function esmChunkedMain() { childProcessGuardPath: path.join(libDir, 'bunfs-child-process-guard.mjs'), vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), + cycleHoists, }, }); @@ -1704,6 +1716,17 @@ async function esmChunkedMain() { globalThis.__claudeBunShim = globalThis.Bun; globalThis.__claudeBun = globalThis.Bun; + let cycleHoists = []; + try { + const auditedVersions = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'config', 'claude-native-audited-versions.json')); + const entry = auditedVersions.versions?.[process.env.CURRENT_CLAUDE_VERSION]; + if (entry && Array.isArray(entry.cycle_hoists)) { + cycleHoists = entry.cycle_hoists; + } + } catch { + // 読み込み失敗時は空配列のまま(fail-closed、既存の同期require経路にフォールバック) + } + register(pathToFileURL(path.join(libDir, 'bunfs-esm-loader.mjs')).href, { parentURL: pathToFileURL(__filename).href, data: { @@ -1712,6 +1735,7 @@ async function esmChunkedMain() { childProcessGuardPath: path.join(libDir, 'bunfs-child-process-guard.mjs'), vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), + cycleHoists, }, }); diff --git a/scripts/add-candidate-metadata.js b/scripts/add-candidate-metadata.js index 9706872..2576c55 100644 --- a/scripts/add-candidate-metadata.js +++ b/scripts/add-candidate-metadata.js @@ -59,6 +59,10 @@ function main() { } versionEntry.num_modules = offsets.num_modules; versionEntry.byte_count = offsets.byte_count; + if (!Object.prototype.hasOwnProperty.call(offsets, 'cycle_hoists')) { + throw new Error('add-candidate-metadata: esm-chunked candidate is missing cycle_hoists field (audit incomplete)'); + } + versionEntry.cycle_hoists = offsets.cycle_hoists; } else { if (!(offsets.entry_js_offset > 0) || !(offsets.entry_end_offset > offsets.entry_js_offset)) { throw new Error('legacy-cjs offsets missing entry_js_offset/entry_end_offset'); diff --git a/scripts/termux-prepare-claude-native-version.js b/scripts/termux-prepare-claude-native-version.js index dd63cd9..4a5d5c9 100755 --- a/scripts/termux-prepare-claude-native-version.js +++ b/scripts/termux-prepare-claude-native-version.js @@ -107,7 +107,189 @@ function discoverLegacyCjsOffsets(buf) { }; } -function discoverEsmChunkedOffsets(binary) { +function analyzeCycleHoists(ownedDirForAnalysis, options = {}) { + const acorn = require('acorn'); + const walk = require('acorn-walk'); + + const EXPECTED_ACORN_VERSION = '8.15.0'; + const actualAcornVersion = options.acornVersionOverride || require('acorn/package.json').version; + if (actualAcornVersion !== EXPECTED_ACORN_VERSION) { + throw new Error(`analyzeCycleHoists: acorn version mismatch: expected ${EXPECTED_ACORN_VERSION}, got ${actualAcornVersion}`); + } + + const PREFIX = '/$bunfs/root/'; + const files = fs.readdirSync(ownedDirForAnalysis).filter((f) => f.endsWith('.js')); + + function stripPrefix(specifier) { + return specifier.startsWith(PREFIX) ? specifier.slice(PREFIX.length) : null; + } + + const staticEdges = new Map(); + const requireEdges = new Map(); + const asts = new Map(); + let parseFailureCount = 0; + const parseFailureFiles = []; + + for (const f of files) { + const src = fs.readFileSync(path.join(ownedDirForAnalysis, f), 'utf8'); + let ast; + try { + ast = acorn.parse(src, { ecmaVersion: 'latest', sourceType: 'module', allowImportExportEverywhere: true }); + } catch (e) { + parseFailureCount += 1; + parseFailureFiles.push(f); + continue; + } + asts.set(f, { ast, src }); + + const si = new Set(); + const ri = new Set(); + + for (const stmt of ast.body) { + if (stmt.type === 'ImportDeclaration' && typeof stmt.source?.value === 'string') { + const t = stripPrefix(stmt.source.value); + if (t) si.add(t); + } + if ( + (stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportAllDeclaration') && + typeof stmt.source?.value === 'string' + ) { + const t = stripPrefix(stmt.source.value); + if (t) si.add(t); + } + } + + walk.simple(ast, { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'MetaProperty' && + node.callee.property.name === 'require' && + node.arguments.length === 1 && + node.arguments[0].type === 'Literal' && + typeof node.arguments[0].value === 'string' + ) { + const t = stripPrefix(node.arguments[0].value); + if (t) ri.add(t); + } + }, + }); + + staticEdges.set(f, si); + requireEdges.set(f, ri); + } + + // fail-closed: 1件でもパース失敗があれば即座にエラー終了(例外を許容しない) + if (parseFailureCount > 0) { + throw new Error(`analyzeCycleHoists: ${parseFailureCount} file(s) failed to parse (${parseFailureFiles.join(', ')}); refusing to generate cycle_hoists (fail-closed)`); + } + + function reaches(from, target, graph) { + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const cur = stack.pop(); + for (const d of graph.get(cur) || []) { + if (d === target) return true; + if (!seen.has(d)) { seen.add(d); stack.push(d); } + } + } + return false; + } + + const staticOnly = []; + for (const [f, reqs] of requireEdges) { + for (const r of reqs) { + if (!staticEdges.has(r)) continue; + if (reaches(r, f, staticEdges)) staticOnly.push([f, r]); + } + } + + function isFunctionNode(n) { + return n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression'; + } + function isIIFE(fnNode, parent) { + return parent && parent.type === 'CallExpression' && parent.callee === fnNode; + } + function isTopLevelEager(ancestors) { + for (let i = ancestors.length - 2; i >= 0; i -= 1) { + const anc = ancestors[i]; + if (isFunctionNode(anc)) { + const parentOfFn = ancestors[i - 1]; + if (isIIFE(anc, parentOfFn)) continue; + return false; + } + } + return true; + } + + const byFile = new Map(); + for (const [f, r] of staticOnly) { + if (!byFile.has(f)) byFile.set(f, new Set()); + byFile.get(f).add(r); + } + + const cycleHoists = []; + + for (const [f, targets] of byFile) { + const { ast, src } = asts.get(f); + const callsForTarget = new Map(); + + walk.fullAncestor(ast, (node, ancestors) => { + if ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'MetaProperty' && + node.callee.property.name === 'require' && + node.arguments.length === 1 && + node.arguments[0].type === 'Literal' && + typeof node.arguments[0].value === 'string' + ) { + const t = stripPrefix(node.arguments[0].value); + if (!t || !targets.has(t)) return; + const eager = isTopLevelEager(ancestors); + if (!callsForTarget.has(t)) callsForTarget.set(t, []); + callsForTarget.get(t).push({ eager, node }); + } + }); + + for (const t of targets) { + const calls = callsForTarget.get(t); + if (!calls || calls.length === 0) continue; + const hasEager = calls.some((c) => c.eager); + if (!hasEager) continue; // 全遅延なら記録しない + + const literal = `import.meta.require("/$bunfs/root/${t}")`; + const expectedOccurrences = src.split(literal).length - 1; + + // assertProperties: eagerな呼出しサイトの直後にある .propertyName を収集 + const assertProperties = new Set(); + for (const c of calls) { + if (!c.eager) continue; + const afterCall = src.slice(c.node.end, c.node.end + 100); + const m = afterCall.match(/^\.([A-Za-z_$][A-Za-z0-9_$]*)/); + if (m) assertProperties.add(m[1]); + } + + cycleHoists.push({ + file: f, + targetModule: t, + expectedOccurrences, + assertProperties: [...assertProperties], + }); + } + } + + return cycleHoists; +} + +function discoverCycleHoists(binary, ownedDirForAnalysis) { + const { extractToProcessOwnedDir } = require(path.join(__dirname, '..', 'packages', 'claude-code', 'lib', 'bunfs-extract.js')); + extractToProcessOwnedDir(binary, ownedDirForAnalysis); + return analyzeCycleHoists(ownedDirForAnalysis); +} + +function discoverEsmChunkedOffsets(binary, packDir) { // StandaloneModuleGraphコンテナ自体はlegacy-cjs(単一CJSラッパー)・esm-chunked // (1387個のESMチャンク)のどちらのバージョンにも存在する(実測確認: 2.1.241でも // 11モジュールのグラフが見つかる)。コンテナの有無では形式を判別できないため、 @@ -127,24 +309,27 @@ function discoverEsmChunkedOffsets(binary) { if (codeStart.startsWith(cjsWrapperPrefix)) { throw new Error('entry module is legacy-cjs wrapped, not esm-chunked'); } + const cycleAnalysisDir = path.join(packDir, 'cycle-analysis'); + const cycleHoists = discoverCycleHoists(binary, cycleAnalysisDir); return { entry_format: 'esm-chunked', num_modules: graph.numModules, byte_count: graph.byteCount, + cycle_hoists: cycleHoists, }; } finally { fs.closeSync(graph.fd); } } -function discoverOffsets(binary) { +function discoverOffsets(binary, packDir) { const binarySize = fs.statSync(binary).size; // esm-chunked検出はトレイラー起点の範囲readSyncのみで完結し、ファイル全体を // メモリへ読み込まない(389MB超のバイナリでOOMを避けるため、こちらを先に試す)。 let esmChunkedError; try { - const esmChunked = discoverEsmChunkedOffsets(binary); + const esmChunked = discoverEsmChunkedOffsets(binary, packDir); return { binary, binary_size: binarySize, ...esmChunked }; } catch (error) { esmChunkedError = error; @@ -181,7 +366,7 @@ function main() { fs.rmSync(nativeDest, { recursive: true, force: true }); fs.cpSync(path.join(extractDir, 'package'), nativeDest, { recursive: true }); - const offsets = discoverOffsets(sourceBin); + const offsets = discoverOffsets(sourceBin, packDir); const result = { version, wrapper_spec: `@anthropic-ai/claude-code@${version}`, @@ -214,9 +399,19 @@ function main() { } } -try { - main(); -} catch (error) { - console.error(error && error.stack ? error.stack : String(error)); - process.exit(1); +module.exports = { + discoverOffsets, + discoverEsmChunkedOffsets, + discoverLegacyCjsOffsets, + discoverCycleHoists, + analyzeCycleHoists, +}; + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); + } } diff --git a/scripts/termux-prepare-claude-native-version.test.js b/scripts/termux-prepare-claude-native-version.test.js new file mode 100644 index 0000000..5677c4b --- /dev/null +++ b/scripts/termux-prepare-claude-native-version.test.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { analyzeCycleHoists } = require('./termux-prepare-claude-native-version.js'); + +function makeTempDir(prefix) { + const baseDir = process.env.TMPDIR || (process.env.PREFIX ? path.join(process.env.PREFIX, 'tmp') : os.tmpdir()); + return fs.mkdtempSync(path.join(baseDir, prefix)); +} + +test('analyzeCycleHoists: structural cycle + eager call', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + // File A imports B statically + fs.writeFileSync( + path.join(tempDir, 'A.js'), + 'import "/$bunfs/root/B.js";\nexport const someExport = "A";\n' + ); + + // File B requires A eagerly (at top level) + fs.writeFileSync( + path.join(tempDir, 'B.js'), + 'var x = import.meta.require("/$bunfs/root/A.js").someExport;\nexport const y = "B";\n' + ); + + const result = analyzeCycleHoists(tempDir); + assert.equal(result.length, 1); + assert.deepEqual(result[0], { + file: 'B.js', + targetModule: 'A.js', + expectedOccurrences: 1, + assertProperties: ['someExport'], + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('analyzeCycleHoists: structural cycle + all-delayed calls', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + // File A imports B statically + fs.writeFileSync( + path.join(tempDir, 'A.js'), + 'import "/$bunfs/root/B.js";\nexport const someExport = "A";\n' + ); + + // File B requires A only inside a function (delayed) + fs.writeFileSync( + path.join(tempDir, 'B.js'), + 'function f() { var x = import.meta.require("/$bunfs/root/A.js").someExport; }\nexport const y = "B";\n' + ); + + const result = analyzeCycleHoists(tempDir); + assert.equal(result.length, 0); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('analyzeCycleHoists: no cycle', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + // File A requires B + fs.writeFileSync( + path.join(tempDir, 'A.js'), + 'var x = import.meta.require("/$bunfs/root/B.js");\nexport const y = "A";\n' + ); + + // File B does not reference A at all + fs.writeFileSync( + path.join(tempDir, 'B.js'), + 'export const z = "B";\n' + ); + + const result = analyzeCycleHoists(tempDir); + assert.equal(result.length, 0); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('analyzeCycleHoists: parse failure throws', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + // Valid file + fs.writeFileSync( + path.join(tempDir, 'A.js'), + 'export const a = 1;\n' + ); + + // Intentionally invalid JS + fs.writeFileSync( + path.join(tempDir, 'B.js'), + 'this is {{{ invalid syntax' + ); + + assert.throws(() => { + analyzeCycleHoists(tempDir); + }, /analyzeCycleHoists: .* file\(s\) failed to parse/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('analyzeCycleHoists: acorn version mismatch throws', () => { + const tempDir = makeTempDir('cycle-hoist-test-'); + try { + // Create a minimal valid file + fs.writeFileSync( + path.join(tempDir, 'A.js'), + 'export const a = 1;\n' + ); + + assert.throws(() => { + analyzeCycleHoists(tempDir, { acornVersionOverride: '9.9.9' }); + }, /analyzeCycleHoists: acorn version mismatch/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); From 66f26b67055ee377abea3efe88e56665de9984b8 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sun, 30 Aug 2026 06:36:51 +0900 Subject: [PATCH 08/10] fix: switch bunfs loader from module.register() to module.registerHooks() to fix hang on cycle_hoists Changes: - Convert resolve() and load() functions from async to sync - Add caller origin gate (fromChunk) to restrict child_process/vm/ws redirects - Replace module.register() with module.registerHooks() for synchronous in-process hooks - Update Node.js version requirement to >=22.15.0 <23.0.0 || >=23.5.0 - Add Node.js version check in bin/claude - Improve load() format detection (ESM vs CommonJS) Co-Authored-By: Claude Sonnet 5 --- packages/claude-code/bin/claude | 10 ++++ packages/claude-code/lib/bunfs-esm-loader.mjs | 32 ++++++++---- .../claude-code/lib/bunfs-esm-loader.test.js | 52 +++++++++---------- .../lib/termux-run-claude-native.sh | 40 +++++++------- packages/claude-code/package.json | 2 +- 5 files changed, 76 insertions(+), 60 deletions(-) diff --git a/packages/claude-code/bin/claude b/packages/claude-code/bin/claude index 5f3a8c6..db2dd0e 100755 --- a/packages/claude-code/bin/claude +++ b/packages/claude-code/bin/claude @@ -16,6 +16,16 @@ NODE="${MAGI_NODE:-node}" if [ -n "${MAGI_NODE:-}" ]; then export PATH="$(dirname -- "$NODE"):$PATH" fi + +if ! "$NODE" -e ' +const [major, minor] = process.versions.node.split(".").map(Number); +const ok = major > 23 || (major === 23 && minor >= 5) || (major === 22 && minor >= 15); +process.exit(ok ? 0 : 1); +'; then + echo "Error: claude-code requires Node.js >=22.15.0 <23.0.0 or >=23.5.0 (module.registerHooks() API). Detected: $("$NODE" --version)" >&2 + exit 1 +fi + CONFIG_FILE="${PACKAGE_DIR}/config/claude-native-audited-versions.json" PACKAGE_VERSION=$("$NODE" -e 'const fs=require("fs"); const path=require("path"); const p=JSON.parse(fs.readFileSync(path.join(process.argv[1],"package.json"),"utf8")); process.stdout.write(p.version)' "${PACKAGE_DIR}") CLAUDE_VERSION="${CLAUDE_TERMUX_CLAUDE_VERSION:-${PACKAGE_VERSION}}" diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index aa0169f..782d33d 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -107,15 +107,21 @@ function buildImportMetaRequirePolyfillPrelude(anchorUrl) { ); } -export async function resolve(specifier, context, nextResolve) { - if (specifier === 'child_process' || specifier === 'node:child_process') { - return { url: pathToFileURL(CHILD_PROCESS_GUARD_PATH).href, shortCircuit: true, format: 'module' }; - } - if (specifier === 'vm' || specifier === 'node:vm') { - return { url: pathToFileURL(VM_GUARD_PATH).href, shortCircuit: true, format: 'module' }; - } - if (specifier === 'ws') { - return { url: pathToFileURL(WS_STUB_PATH).href, shortCircuit: true, format: 'module' }; +export function resolve(specifier, context, nextResolve) { + const parentURL = context && context.parentURL; + const ownedPrefix = pathToFileURL(PROCESS_OWNED_DIR + path.sep).href; + const fromChunk = typeof parentURL === 'string' && parentURL.startsWith(ownedPrefix); + + if (fromChunk) { + if (specifier === 'child_process' || specifier === 'node:child_process') { + return { url: pathToFileURL(CHILD_PROCESS_GUARD_PATH).href, shortCircuit: true, format: 'module' }; + } + if (specifier === 'vm' || specifier === 'node:vm') { + return { url: pathToFileURL(VM_GUARD_PATH).href, shortCircuit: true, format: 'module' }; + } + if (specifier === 'ws') { + return { url: pathToFileURL(WS_STUB_PATH).href, shortCircuit: true, format: 'module' }; + } } if (specifier.startsWith('/$bunfs/root/')) { const rel = specifier.slice('/$bunfs/root/'.length); @@ -134,7 +140,7 @@ export async function resolve(specifier, context, nextResolve) { return nextResolve(specifier, context); } -export async function load(url, context, nextLoad) { +export function load(url, context, nextLoad) { const ownedPrefix = pathToFileURL(PROCESS_OWNED_DIR + path.sep).href; if (!url.startsWith(ownedPrefix)) { return nextLoad(url, context); @@ -157,5 +163,9 @@ export async function load(url, context, nextLoad) { } else if (hoistedImportLine) { source = hoistedImportLine + source; } - return { format: 'module', source, shortCircuit: true }; + const hasImport = /\bimport\s+/.test(source); + const hasExport = /\b(?:export|import\.meta\.require)\b/.test(source); + const hasCJSExports = /(\W|^)module\.exports\b/.test(source); + const format = !hasImport && !hasExport && hasCJSExports ? 'commonjs' : 'module'; + return { format, source, shortCircuit: true }; } diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index cdc7a2c..71f1a9f 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -33,15 +33,15 @@ test('resolve() handles child_process and node:child_process specifiers', async wsStubPath: path.join(tempDir, 'ws-stub.mjs'), }); - const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + const nextResolve = (spec, ctx) => ({ url: `unresolved:${spec}` }); // child_process should resolve to childProcessGuardPath - const result1 = await loader.resolve('child_process', {}, nextResolve); + const result1 = loader.resolve('child_process', { parentURL: pathToFileURL(path.join(tempDir, 'dummy-chunk.js')).href }, nextResolve); assert.ok(result1.url.includes(guardPath)); assert.equal(result1.shortCircuit, true); // node:child_process should also resolve to childProcessGuardPath - const result2 = await loader.resolve('node:child_process', {}, nextResolve); + const result2 = loader.resolve('node:child_process', { parentURL: pathToFileURL(path.join(tempDir, 'dummy-chunk.js')).href }, nextResolve); assert.ok(result2.url.includes(guardPath)); assert.equal(result2.shortCircuit, true); } finally { @@ -65,15 +65,15 @@ test('resolve() handles vm and node:vm specifiers', async () => { wsStubPath: path.join(tempDir, 'ws-stub.mjs'), }); - const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + const nextResolve = (spec, ctx) => ({ url: `unresolved:${spec}` }); // vm should resolve to vmGuardPath - const result1 = await loader.resolve('vm', {}, nextResolve); + const result1 = loader.resolve('vm', { parentURL: pathToFileURL(path.join(tempDir, 'dummy-chunk.js')).href }, nextResolve); assert.ok(result1.url.includes(vmGuardPath)); assert.equal(result1.shortCircuit, true); // node:vm should also resolve to vmGuardPath - const result2 = await loader.resolve('node:vm', {}, nextResolve); + const result2 = loader.resolve('node:vm', { parentURL: pathToFileURL(path.join(tempDir, 'dummy-chunk.js')).href }, nextResolve); assert.ok(result2.url.includes(vmGuardPath)); assert.equal(result2.shortCircuit, true); } finally { @@ -97,9 +97,9 @@ test('resolve() handles ws specifier', async () => { wsStubPath: wsStubPath, }); - const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + const nextResolve = (spec, ctx) => ({ url: `unresolved:${spec}` }); - const result = await loader.resolve('ws', {}, nextResolve); + const result = loader.resolve('ws', { parentURL: pathToFileURL(path.join(tempDir, 'dummy-chunk.js')).href }, nextResolve); assert.ok(result.url.includes(wsStubPath)); assert.equal(result.shortCircuit, true); } finally { @@ -150,9 +150,9 @@ test('resolve() rejects path traversal with ..', async () => { wsStubPath: path.join(tempDir, 'ws-stub.mjs'), }); - const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + const nextResolve = (spec, ctx) => ({ url: `unresolved:${spec}` }); - await assert.rejects( + assert.throws( () => loader.resolve('/$bunfs/root/../../etc/passwd', {}, nextResolve), /rejected specifier|escapes/, ); @@ -175,9 +175,9 @@ test('resolve() rejects absolute paths', async () => { wsStubPath: path.join(tempDir, 'ws-stub.mjs'), }); - const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + const nextResolve = (spec, ctx) => ({ url: `unresolved:${spec}` }); - await assert.rejects( + assert.throws( () => loader.resolve('/$bunfs/root//etc/passwd', {}, nextResolve), /rejected specifier|escapes/, ); @@ -200,9 +200,9 @@ test('resolve() throws error for missing extracted module', async () => { wsStubPath: path.join(tempDir, 'ws-stub.mjs'), }); - const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + const nextResolve = (spec, ctx) => ({ url: `unresolved:${spec}` }); - await assert.rejects( + assert.throws( () => loader.resolve('/$bunfs/root/nonexistent.js', {}, nextResolve), /missing extracted module/, ); @@ -362,7 +362,7 @@ test('load() hoists the cycle-breaking import.meta.require call in chunk-vmw9kxh }); test('import.meta.require resolves /$bunfs/root/ specifiers via loader integration', async () => { - const { register } = await import('node:module'); + const { registerHooks } = await import('node:module'); const loader = await import('./bunfs-esm-loader.mjs'); const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-integration-${process.pid}-${Date.now()}`); fs.mkdirSync(tempDir, { recursive: true }); @@ -390,19 +390,17 @@ test('import.meta.require resolves /$bunfs/root/ specifiers via loader integrati const sourceBin = path.join(tempDir, 'dummy-bin'); fs.writeFileSync(sourceBin, '#!/bin/false'); - register(pathToFileURL(path.join(__dirname, 'bunfs-esm-loader.mjs')).href, { - parentURL: pathToFileURL(__filename).href, - data: { - processOwnedDir: tempDir, - sourceBin: sourceBin, - childProcessGuardPath: path.join(tempDir, 'guard.mjs'), - vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), - wsStubPath: path.join(tempDir, 'ws-stub.mjs'), - cycleHoists: [ - { file: 'chunk-vmw9kxhv.js', targetModule: 'chunk-y0jj307t.js', expectedOccurrences: 1, assertProperties: [] }, - ], - }, + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: sourceBin, + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + cycleHoists: [ + { file: 'chunk-vmw9kxhv.js', targetModule: 'chunk-y0jj307t.js', expectedOccurrences: 1, assertProperties: [] }, + ], }); + registerHooks({ resolve: loader.resolve, load: loader.load }); // Test 1: Normal case - should load and resolve correctly const okModule = await import(pathToFileURL(okCallerPath).href); diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index de40f00..3a2c93f 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -665,7 +665,7 @@ function rewriteNativeChunkSource(source) { async function esmChunkedMain() { const { prepareProcessOwnedDir } = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib', 'bunfs-extract.js')); - const { register } = require('node:module'); + const { registerHooks } = require('node:module'); const { pathToFileURL } = require('node:url'); const { ownedDir, entryRelPath } = prepareProcessOwnedDir(sourceBin, workdir); @@ -701,17 +701,16 @@ async function esmChunkedMain() { // 読み込み失敗時は空配列のまま(fail-closed、既存の同期require経路にフォールバック) } - register(pathToFileURL(path.join(libDir, 'bunfs-esm-loader.mjs')).href, { - parentURL: pathToFileURL(__filename).href, - data: { - processOwnedDir: ownedDir, - sourceBin, - childProcessGuardPath: path.join(libDir, 'bunfs-child-process-guard.mjs'), - vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), - wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), - cycleHoists, - }, + const loaderMod = require(path.join(libDir, 'bunfs-esm-loader.mjs')); + loaderMod.initialize({ + processOwnedDir: ownedDir, + sourceBin, + childProcessGuardPath: path.join(libDir, 'bunfs-child-process-guard.mjs'), + vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), + wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), + cycleHoists, }); + registerHooks({ resolve: loaderMod.resolve, load: loaderMod.load }); const entryUrl = pathToFileURL(path.join(ownedDir, entryRelPath)).href; @@ -1727,17 +1726,16 @@ async function esmChunkedMain() { // 読み込み失敗時は空配列のまま(fail-closed、既存の同期require経路にフォールバック) } - register(pathToFileURL(path.join(libDir, 'bunfs-esm-loader.mjs')).href, { - parentURL: pathToFileURL(__filename).href, - data: { - processOwnedDir: ownedDir, - sourceBin, - childProcessGuardPath: path.join(libDir, 'bunfs-child-process-guard.mjs'), - vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), - wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), - cycleHoists, - }, + const loaderMod = require(path.join(libDir, 'bunfs-esm-loader.mjs')); + loaderMod.initialize({ + processOwnedDir: ownedDir, + sourceBin, + childProcessGuardPath: path.join(libDir, 'bunfs-child-process-guard.mjs'), + vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), + wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), + cycleHoists, }); + registerHooks({ resolve: loaderMod.resolve, load: loaderMod.load }); const entryUrl = pathToFileURL(path.join(ownedDir, entryRelPath)).href; diff --git a/packages/claude-code/package.json b/packages/claude-code/package.json index 8a59963..2391a42 100644 --- a/packages/claude-code/package.json +++ b/packages/claude-code/package.json @@ -15,7 +15,7 @@ "LICENSE" ], "engines": { - "node": ">=20.6.0" + "node": ">=22.15.0 <23.0.0 || >=23.5.0" }, "keywords": [ "claude-code", From 6202b13fd5171aee5b8bade7ea05349325181f52 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sun, 30 Aug 2026 06:44:24 +0900 Subject: [PATCH 09/10] fix: revert unauthorized CJS format-detection heuristic in bunfs loader The prior commit (66f26b6) added a regex-based commonjs/module format detector to load() to work around a failing integration test. Verified against a real 2.1.248 extraction (1768 chunk files) that PROCESS_OWNED_DIR never contains genuine CommonJS files (0 use module.exports without import/export syntax) - the failure was caused by the test's synthetic foo.js fixture using module.exports, not a real production case. Reverted load() to always return format: 'module' and fixed the fixture to use ESM syntax instead. Co-Authored-By: Claude Sonnet 5 --- packages/claude-code/lib/bunfs-esm-loader.mjs | 6 +----- packages/claude-code/lib/bunfs-esm-loader.test.js | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index 782d33d..05de77d 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -163,9 +163,5 @@ export function load(url, context, nextLoad) { } else if (hoistedImportLine) { source = hoistedImportLine + source; } - const hasImport = /\bimport\s+/.test(source); - const hasExport = /\b(?:export|import\.meta\.require)\b/.test(source); - const hasCJSExports = /(\W|^)module\.exports\b/.test(source); - const format = !hasImport && !hasExport && hasCJSExports ? 'commonjs' : 'module'; - return { format, source, shortCircuit: true }; + return { format: 'module', source, shortCircuit: true }; } diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index 71f1a9f..ba3c652 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -373,8 +373,11 @@ test('import.meta.require resolves /$bunfs/root/ specifiers via loader integrati fs.writeFileSync(path.join(tempDir, 'vm-guard.mjs'), 'export default {};'); fs.writeFileSync(path.join(tempDir, 'ws-stub.mjs'), 'export default {};'); - // Create CommonJS fixture that will be required (normal case) - fs.writeFileSync(path.join(tempDir, 'foo.js'), 'module.exports = { value: 42 };'); + // Create ESM fixture that will be required via import.meta.require (normal case). + // PROCESS_OWNED_DIR only ever contains genuine ESM chunk files extracted from the + // Bun esm-chunked bundle (verified against a real 2.1.248 extraction: 0 of 1768 + // chunk files are CommonJS), so load() always returns format: 'module' for this dir. + fs.writeFileSync(path.join(tempDir, 'foo.js'), 'export const value = 42;'); // Create ESM files for each test scenario const okCallerPath = path.join(tempDir, 'ok-caller.mjs'); From d87ac01588ac5082f7cb34515a5e45d8a7acc18e Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sun, 30 Aug 2026 06:52:42 +0900 Subject: [PATCH 10/10] fix: fix registerHooks reference error in second bootstrap path (G3 blocker) The second esmChunkedMain() bootstrap path (used when CLAUDE_TERMUX_STDIN=inherit, i.e. TUI/interactive sessions) still destructured `register` from node:module while calling the undefined `registerHooks(...)`, causing a ReferenceError not reachable via the default -p smoke test path. Found by G3 review (terra). Fixed the destructure to match the first path and verified both paths (-p default and CLAUDE_TERMUX_STDIN=inherit) succeed on 2.1.248 and 2.1.245. Co-Authored-By: Claude Sonnet 5 --- packages/claude-code/lib/termux-run-claude-native.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index 3a2c93f..14f0267 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -1690,7 +1690,7 @@ function rewriteNativeChunkSource(source) { async function esmChunkedMain() { const { prepareProcessOwnedDir } = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib', 'bunfs-extract.js')); - const { register } = require('node:module'); + const { registerHooks } = require('node:module'); const { pathToFileURL } = require('node:url'); const { ownedDir, entryRelPath } = prepareProcessOwnedDir(sourceBin, workdir);