diff --git a/.github/workflows/npm-package.yml b/.github/workflows/npm-package.yml index 6eb5f27..a71de05 100644 --- a/.github/workflows/npm-package.yml +++ b/.github/workflows/npm-package.yml @@ -90,6 +90,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/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/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/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 1552dd8..05de77d 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -9,12 +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 : []; +} + +function tryHoistCycleBreakingImports(filePath, source) { + const rel = path.relative(PROCESS_OWNED_DIR, filePath); + 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(); + + 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; + + const real = path.resolve(PROCESS_OWNED_DIR, record.targetModule); + if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) continue; + if (!existsSync(real)) continue; + + 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) { @@ -22,24 +60,68 @@ 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` + + `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` + ` 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` + + ` 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` + `};\n` ); } -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); @@ -58,17 +140,28 @@ 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); } const filePath = fileURLToPath(url); let source = readFileSync(filePath, 'utf8'); + + let hoistedImportLine = ''; + const hoistResult = tryHoistCycleBreakingImports(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 b1a28c1..ba3c652 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/, ); @@ -327,3 +327,162 @@ test('load() calls nextLoad for URLs outside processOwnedDir', async () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +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'), + cycleHoists: [{ file: 'chunk-vmw9kxhv.js', targetModule: 'chunk-y0jj307t.js', expectedOccurrences: 1, assertProperties: [] }], + }); + + 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 { 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 }); + + 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 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'); + 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'); + + 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); + 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/, + ); + + // 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'); + + 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 }); + } +}); diff --git a/packages/claude-code/lib/native-validators.js b/packages/claude-code/lib/native-validators.js new file mode 100644 index 0000000..208142e --- /dev/null +++ b/packages/claude-code/lib/native-validators.js @@ -0,0 +1,90 @@ +'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 (!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}`); + } + 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..8c0d649 --- /dev/null +++ b/packages/claude-code/lib/native-validators.test.js @@ -0,0 +1,160 @@ +'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', + cycle_hoists: [], + }; + 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', + cycle_hoists: [], + }; + 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', + cycle_hoists: [], + }; + 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 82028fe..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'); @@ -16,6 +15,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 +28,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 +37,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 +46,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 }); } @@ -107,70 +108,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}`); - } - 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); - } -} diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index 08941fb..14f0267 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); @@ -690,16 +690,27 @@ async function esmChunkedMain() { globalThis.__claudeBunShim = globalThis.Bun; globalThis.__claudeBun = globalThis.Bun; - 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'), - }, + 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経路にフォールバック) + } + + 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; @@ -1679,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); @@ -1704,16 +1715,27 @@ async function esmChunkedMain() { globalThis.__claudeBunShim = globalThis.Bun; globalThis.__claudeBun = globalThis.Bun; - 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'), - }, + 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経路にフォールバック) + } + + 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", 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 }); + } +});