From f16ab8472485c1f8909480d3101cdf8f3a7cd0cb Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Mon, 31 Aug 2026 15:08:32 +0900 Subject: [PATCH 1/2] fix: intercept fs.readFileSync/readFile for /$bunfs/root/ paths to fix -p crash on 2.1.251 upstream 2.1.251 introduced embedded text assets (plugin-eval docs) read via plain fs.readFileSync/fs.promises.readFile with /$bunfs/root/ virtual paths, bypassing the existing import.meta.require-only patch. Adds installFsBunfsInterception() with syncBuiltinESMExports() (required for ESM named-import bindings), zstd support in the Bun shim (node:zlib), engines/version-gate bump to Node >=23.8.0 (zstd landed at 22.15.0/23.8.0, not 23.5.0), and shared resolveBunfsPath() to dedupe path validation. G1(4x)/G2(2x) Go. Co-Authored-By: Claude Sonnet 5 --- packages/claude-code/bin/claude | 4 +- packages/claude-code/lib/bunfs-esm-loader.mjs | 95 ++++- .../claude-code/lib/bunfs-esm-loader.test.js | 373 ++++++++++++++++++ .../lib/termux-run-claude-native.sh | 8 + .../lib/termux-run-claude-native.test.js | 123 ++++++ packages/claude-code/package.json | 2 +- 6 files changed, 586 insertions(+), 19 deletions(-) diff --git a/packages/claude-code/bin/claude b/packages/claude-code/bin/claude index db2dd0e..a8de0fc 100755 --- a/packages/claude-code/bin/claude +++ b/packages/claude-code/bin/claude @@ -19,10 +19,10 @@ 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); +const ok = major > 23 || (major === 23 && minor >= 8) || (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 + echo "Error: claude-code requires Node.js >=22.15.0 <23.0.0 or >=23.8.0 (module.registerHooks() API + zlib zstd support). Detected: $("$NODE" --version)" >&2 exit 1 fi diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index 0b6e063..c27709b 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -1,8 +1,10 @@ import { pathToFileURL, fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; import path from 'node:path'; +const require = createRequire(import.meta.url); + let PROCESS_OWNED_DIR = null; let SOURCE_BIN = null; let CHILD_PROCESS_GUARD_PATH = null; @@ -37,6 +39,19 @@ function recoverMissing(realPath, now = Date.now()) { return ok; } +function resolveBunfsPath(id) { + if (typeof id !== 'string' || !id.startsWith('/$bunfs/root/')) return null; + const rel = id.slice('/$bunfs/root/'.length); + if (rel.includes('..') || path.isAbsolute(rel)) { + throw new Error(`bunfs: rejected specifier ${id}`); + } + const real = path.resolve(PROCESS_OWNED_DIR, rel); + if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) { + throw new Error(`bunfs: path escapes process-owned dir: ${id}`); + } + return real; +} + export function initialize(data) { PROCESS_OWNED_DIR = data.processOwnedDir; SOURCE_BIN = data.sourceBin; @@ -46,6 +61,7 @@ export function initialize(data) { CYCLE_HOISTS = Array.isArray(data.cycleHoists) ? data.cycleHoists : []; REEXTRACT = typeof data.reExtract === 'function' ? data.reExtract : null; globalThis.__bunfsRecoverMissing = recoverMissing; + globalThis.__bunfsResolvePath = resolveBunfsPath; // 回復状態のリセット (テスト隔離・再 initialize 対応) reExtractConsecFailures = 0; lastReExtractMs = 0; @@ -116,13 +132,11 @@ function buildImportMetaRequirePolyfillPrelude(anchorUrl) { ` 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` + + ` let real;\n` + + ` try {\n` + + ` real = globalThis.__bunfsResolvePath(id);\n` + + ` } catch (_eResolve) {\n` + + ` throw new Error("bunfs meta-require: " + _eResolve.message);\n` + ` }\n` + ` if (!__bunfsMetaRequireExistsSync(real)) {\n` + ` const _rec = (typeof globalThis.__bunfsRecoverMissing === "function") && globalThis.__bunfsRecoverMissing(real);\n` + @@ -169,13 +183,11 @@ export function resolve(specifier, context, nextResolve) { } } if (specifier.startsWith('/$bunfs/root/')) { - const rel = specifier.slice('/$bunfs/root/'.length); - if (rel.includes('..') || path.isAbsolute(rel)) { - throw new Error(`bunfs resolve: rejected specifier ${specifier}`); - } - const real = path.resolve(PROCESS_OWNED_DIR, rel); - if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) { - throw new Error(`bunfs resolve: path escapes process-owned dir: ${specifier}`); + let real; + try { + real = resolveBunfsPath(specifier); + } catch (e) { + throw new Error(`bunfs resolve: ${e.message}`); } if (!existsSync(real) && !recoverMissing(real)) { throw new Error(`bunfs resolve: missing extracted module ${specifier} -> ${real}`); @@ -221,4 +233,55 @@ export function load(url, context, nextLoad) { return { format: 'module', source, shortCircuit: true }; } -export { recoverMissing }; +let FS_PATCHED = false; +function installFsBunfsInterception() { + if (FS_PATCHED) return; + const fsMod = require('node:fs'); + const zlib = require('node:zlib'); + if (typeof zlib.zstdDecompressSync !== 'function' || typeof zlib.zstdDecompress !== 'function') { + throw new Error('bunfs: Node.js zlib zstd support not found. Please upgrade to Node.js >=23.8.0 or >=22.15.0 (LTS).'); + } + const origReadFileSync = fsMod.readFileSync; + const origReadFile = fsMod.readFile; + const origPromisesReadFile = fsMod.promises.readFile; + + function resolveOrRecover(p) { + const real = resolveBunfsPath(p); + if (real === null) return null; + if (!existsSync(real)) { + const recovered = (typeof globalThis.__bunfsRecoverMissing === 'function') && globalThis.__bunfsRecoverMissing(real); + if (!recovered) throw new Error(`bunfs fs-intercept: missing extracted asset ${p} -> ${real}`); + } + const realOwned = fsMod.realpathSync(PROCESS_OWNED_DIR); + const realTarget = fsMod.realpathSync(real); + if (path.relative(realOwned, realTarget).startsWith('..')) { + throw new Error(`bunfs fs-intercept: resolved path escapes owned dir via symlink: ${p}`); + } + return real; + } + + try { + fsMod.readFileSync = function (p, ...rest) { + const real = typeof p === 'string' ? resolveOrRecover(p) : null; + return Reflect.apply(origReadFileSync, this, [real !== null ? real : p, ...rest]); + }; + fsMod.readFile = function (p, ...rest) { + const real = typeof p === 'string' ? resolveOrRecover(p) : null; + return Reflect.apply(origReadFile, this, [real !== null ? real : p, ...rest]); + }; + fsMod.promises.readFile = function (p, ...rest) { + const real = typeof p === 'string' ? resolveOrRecover(p) : null; + return Reflect.apply(origPromisesReadFile, this, [real !== null ? real : p, ...rest]); + }; + syncBuiltinESMExports(); + } catch (e) { + fsMod.readFileSync = origReadFileSync; + fsMod.readFile = origReadFile; + fsMod.promises.readFile = origPromisesReadFile; + try { syncBuiltinESMExports(); } catch { /* 復元目的、失敗しても re-throw を優先 */ } + throw e; + } + FS_PATCHED = true; +} + +export { recoverMissing, resolveBunfsPath, installFsBunfsInterception }; diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index a8f88cd..603db13 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -899,3 +899,376 @@ test('T8-exec: recoverMissing returns true immediately for an existing file with fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +// New tests for fs interception functionality + +test('resolveBunfsPath: non-target paths return null', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-resolve-path-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + 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 result = loader.resolveBunfsPath('/regular/path/file.js'); + assert.equal(result, null); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolveBunfsPath: rejects path traversal with ..', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-resolve-path-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + 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'), + }); + + assert.throws( + () => loader.resolveBunfsPath('/$bunfs/root/../../etc/passwd'), + /rejected specifier/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolveBunfsPath: resolves valid /$bunfs/root/ paths correctly', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-resolve-path-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const targetFile = path.join(tempDir, 'foo.js'); + fs.writeFileSync(targetFile, 'export const x = 1;'); + + 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 result = loader.resolveBunfsPath('/$bunfs/root/foo.js'); + assert.ok(result); + assert.equal(result, targetFile); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('syncBuiltinESMExports: fs interception requires it for ESM sync', async () => { + const { spawnSync } = require('node:child_process'); + const tempDir = path.join(os.tmpdir(), `bunfs-sync-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const testScript = path.join(tempDir, 'test-sync.mjs'); + fs.writeFileSync(testScript, ` +import fs from 'node:fs'; +const origRead = fs.readFileSync; + +// ESM fixture - import it first +import { readFileSync as esm_read } from 'node:fs'; +console.log('ESM import done'); + +// Replace fs.readFileSync without syncBuiltinESMExports +fs.readFileSync = () => 'replaced'; + +// Try to call from ESM - should use old version +const result1 = esm_read; +console.log('Before sync: ' + (result1 === origRead ? 'original' : 'unknown')); + +// Now simulate syncBuiltinESMExports effect +const { syncBuiltinESMExports } = await import('node:module'); +syncBuiltinESMExports(); + +// Try to call again - should use new version +const { readFileSync: esm_read2 } = await import('node:fs'); +console.log('After sync: ' + (esm_read2 === fs.readFileSync ? 'replaced' : 'original')); +`); + + const result = spawnSync('node', [testScript], { encoding: 'utf8' }); + assert.equal(result.status, 0, `Script failed: ${result.stderr}`); + assert.ok(result.stdout.includes('ESM import done')); + // The actual behavior depends on Node version, but the test structure proves the concept + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('fs.readFile callback mode: resolves /$bunfs/root/ paths', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-fs-readfile-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const testFile = path.join(tempDir, 'test.js'); + const testContent = 'export const y = 42;'; + fs.writeFileSync(testFile, testContent); + + 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'), + }); + + loader.installFsBunfsInterception(); + + // After interception is installed, fs.readFile should resolve bunfs paths + const testFsModule = require('node:fs'); + let callbackCalled = false; + let readData = null; + + testFsModule.readFile('/$bunfs/root/test.js', 'utf8', (err, data) => { + callbackCalled = true; + if (!err) { + readData = data; + } + }); + + // Give callback time to execute + await new Promise(resolve => setTimeout(resolve, 100)); + assert.equal(callbackCalled, true); + assert.equal(readData, testContent); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('fs.readFile with options: resolves /$bunfs/root/ paths', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-fs-readfile-opts-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const testFile = path.join(tempDir, 'test.txt'); + const testContent = 'Hello World'; + fs.writeFileSync(testFile, testContent); + + 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'), + }); + + loader.installFsBunfsInterception(); + + const testFsModule = require('node:fs'); + let callbackCalled = false; + let readData = null; + + testFsModule.readFile('/$bunfs/root/test.txt', { encoding: 'utf8' }, (err, data) => { + callbackCalled = true; + if (!err) { + readData = data; + } + }); + + await new Promise(resolve => setTimeout(resolve, 100)); + assert.equal(callbackCalled, true); + assert.equal(readData, testContent); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('fs.readFileSync: non-bunfs paths unchanged after interception', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-fs-normal-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const normalFile = path.join(tempDir, 'normal.txt'); + const normalContent = 'Normal File Content'; + fs.writeFileSync(normalFile, normalContent); + + 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'), + }); + + loader.installFsBunfsInterception(); + + const testFsModule = require('node:fs'); + const data = testFsModule.readFileSync(normalFile, 'utf8'); + assert.equal(data, normalContent); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('fs interception: symlink escape detection', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-symlink-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const externalDir = path.join(os.tmpdir(), `bunfs-external-${process.pid}-${Date.now()}`); + fs.mkdirSync(externalDir, { recursive: true }); + const externalFile = path.join(externalDir, 'external.txt'); + fs.writeFileSync(externalFile, 'External'); + + 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'), + }); + + loader.installFsBunfsInterception(); + + // Try to create a symlink (may fail on some platforms) + const symlinkPath = path.join(tempDir, 'escape.txt'); + try { + fs.symlinkSync(externalFile, symlinkPath); + } catch (e) { + // Skip test if symlinks not supported + return; + } + + const testFsModule = require('node:fs'); + assert.throws( + () => testFsModule.readFileSync('/$bunfs/root/escape.txt'), + /escapes owned dir via symlink/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(externalDir, { recursive: true, force: true }); + } +}); + +test('installFsBunfsInterception: idempotent (multiple calls are safe)', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-idempotent-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const testFile = path.join(tempDir, 'test.js'); + fs.writeFileSync(testFile, 'export const z = 1;'); + + 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'), + }); + + // Call installFsBunfsInterception multiple times + loader.installFsBunfsInterception(); + loader.installFsBunfsInterception(); + loader.installFsBunfsInterception(); + + // Verify fs still works + const testFsModule = require('node:fs'); + const data = testFsModule.readFileSync(testFile, 'utf8'); + assert.ok(data.includes('z = 1')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('fs interception: FS_PATCHED flag prevents double-patching', async () => { + const { spawnSync } = require('node:child_process'); + const tempDir = path.join(os.tmpdir(), `bunfs-no-double-patch-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const testScript = path.join(tempDir, 'test-idempotent.mjs'); + fs.writeFileSync(testScript, ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); + +const loader = await import('./bunfs-esm-loader.mjs'); +const tempDir = process.argv[1]; +const testFile = require('node:path').join(tempDir, 'test.txt'); +require('node:fs').writeFileSync(testFile, 'test content'); + +loader.initialize({ + processOwnedDir: tempDir, + sourceBin: '/dummy/bin', + childProcessGuardPath: require('node:path').join(tempDir, 'guard.mjs'), + vmGuardPath: require('node:path').join(tempDir, 'vm-guard.mjs'), + wsStubPath: require('node:path').join(tempDir, 'ws-stub.mjs'), +}); + +// Create dummy guard files +require('node:fs').writeFileSync(require('node:path').join(tempDir, 'guard.mjs'), 'export default {}'); +require('node:fs').writeFileSync(require('node:path').join(tempDir, 'vm-guard.mjs'), 'export default {}'); +require('node:fs').writeFileSync(require('node:path').join(tempDir, 'ws-stub.mjs'), 'export default {}'); + +// Apply interception twice +loader.installFsBunfsInterception(); +loader.installFsBunfsInterception(); + +// Read normal file twice - should work both times +const fs = require('node:fs'); +const content1 = fs.readFileSync(testFile, 'utf8'); +const content2 = fs.readFileSync(testFile, 'utf8'); + +console.log('Content1:', content1); +console.log('Content2:', content2); +console.log('Match:', content1 === content2); +`); + + // This test would require resolving import paths in the subprocess + // For now, we verify the idempotency through the sync test above + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('recoverMissing integration: fs interception collaborates with recovery', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-integration-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const targetFile = path.join(tempDir, 'recovered.js'); + fs.writeFileSync(targetFile, 'export const recovered = true;'); + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary content'); + + try { + let reExtractCalls = 0; + 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'), + reExtract: (sb, od) => { + reExtractCalls++; + fs.writeFileSync(targetFile, 'export const recovered = true;'); + }, + }); + + loader.installFsBunfsInterception(); + + // Delete the file + fs.unlinkSync(targetFile); + + // Try to read via fs - should trigger recovery + const testFsModule = require('node:fs'); + const data = testFsModule.readFileSync('/$bunfs/root/recovered.js', 'utf8'); + assert.ok(data.includes('recovered')); + assert.equal(reExtractCalls, 1); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index 407541a..2ffd559 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -686,6 +686,9 @@ async function esmChunkedMain() { }, gc: () => {}, YAML: globalThis.__claudeYaml, + zstdDecompressSync: (buf) => require('node:zlib').zstdDecompressSync(buf), + zstdDecompress: (buf) => new Promise((res, rej) => + require('node:zlib').zstdDecompress(buf, (e, r) => (e ? rej(e) : res(r)))), }; Object.defineProperty(process.versions, 'bun', { value: '1.1.8', configurable: true }); globalThis.__claudeBunShim = globalThis.Bun; @@ -712,6 +715,7 @@ async function esmChunkedMain() { cycleHoists, reExtract: (sb, od) => extractToProcessOwnedDir(sb, od), }); + loaderMod.installFsBunfsInterception(); registerHooks({ resolve: loaderMod.resolve, load: loaderMod.load }); const entryUrl = pathToFileURL(path.join(ownedDir, entryRelPath)).href; @@ -1712,6 +1716,9 @@ async function esmChunkedMain() { }, gc: () => {}, YAML: globalThis.__claudeYaml, + zstdDecompressSync: (buf) => require('node:zlib').zstdDecompressSync(buf), + zstdDecompress: (buf) => new Promise((res, rej) => + require('node:zlib').zstdDecompress(buf, (e, r) => (e ? rej(e) : res(r)))), }; Object.defineProperty(process.versions, 'bun', { value: '1.1.8', configurable: true }); globalThis.__claudeBunShim = globalThis.Bun; @@ -1738,6 +1745,7 @@ async function esmChunkedMain() { cycleHoists, reExtract: (sb, od) => extractToProcessOwnedDir(sb, od), }); + loaderMod.installFsBunfsInterception(); registerHooks({ resolve: loaderMod.resolve, load: loaderMod.load }); const entryUrl = pathToFileURL(path.join(ownedDir, entryRelPath)).href; diff --git a/packages/claude-code/lib/termux-run-claude-native.test.js b/packages/claude-code/lib/termux-run-claude-native.test.js index 7f375fe..fef2c58 100644 --- a/packages/claude-code/lib/termux-run-claude-native.test.js +++ b/packages/claude-code/lib/termux-run-claude-native.test.js @@ -1692,3 +1692,126 @@ test('Bun.spawn forwards top-level stdin in the first stdio position', () => { } } }); + +// New tests for fs interception and zstd support + +// esmChunkedMain() (esm-chunked 形式、今回の fs-intercept 修正の対象) の関数本体だけを抽出する。 +// legacyCjsMain() の Bun shim (zstd 非対応でよい、意図的に無変更) を誤って対象に含めないため、 +// 「both esmChunkedMain blocks have correct hook registration order」テストと同じ境界抽出方式を使う。 +function extractEsmChunkedMainBlocks() { + const marker = 'async function esmChunkedMain()'; + const blocks = []; + let offset = 0; + while ((offset = script.indexOf(marker, offset)) !== -1) { + const blockStart = offset; + const blockEnd = script.indexOf('\nasync function', offset + 1); + const actualBlockEnd = blockEnd !== -1 ? blockEnd : script.length; + blocks.push(script.slice(blockStart, actualBlockEnd)); + offset = actualBlockEnd; + } + return blocks; +} + +test('both esmChunkedMain Bun shim blocks contain zstdDecompressSync and zstdDecompress fields', () => { + const blocks = extractEsmChunkedMainBlocks(); + assert.ok(blocks.length >= 2, 'should have at least 2 esmChunkedMain blocks (helper and bootstrap)'); + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + assert.ok( + block.includes('zstdDecompressSync:'), + `Block ${i}: missing zstdDecompressSync field`, + ); + assert.ok( + block.includes('zstdDecompress:'), + `Block ${i}: missing zstdDecompress field`, + ); + } +}); + +test('zstd functions are properly defined for sync decompression', () => { + const blocks = extractEsmChunkedMainBlocks(); + assert.ok(blocks.length >= 1, 'should have at least 1 esmChunkedMain block'); + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + const syncMatch = block.match(/zstdDecompressSync:\s*\([^)]*\)\s*=>\s*require\('node:zlib'\)\.zstdDecompressSync\([^)]*\)/); + assert.ok(syncMatch, `Block ${i}: zstdDecompressSync should call require("node:zlib").zstdDecompressSync`); + + const asyncMatch = block.match(/zstdDecompress:\s*\([^)]*\)\s*=>\s*new Promise/); + assert.ok(asyncMatch, `Block ${i}: zstdDecompress should return a Promise`); + } +}); + +test('zstd decompression works with real zlib.zstd APIs', async () => { + const zlib = require('node:zlib'); + + // Skip if zstd not available + if (typeof zlib.zstdCompressSync !== 'function') { + return; + } + + const testData = Buffer.from('Hello, compression world!'); + const compressed = zlib.zstdCompressSync(testData); + + // Test sync decompression + const decompressedSync = zlib.zstdDecompressSync(compressed); + assert.deepEqual(decompressedSync, testData); + + // Test async decompression + const decompressedAsync = await new Promise((resolve, reject) => { + zlib.zstdDecompress(compressed, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); + assert.deepEqual(decompressedAsync, testData); +}); + +test('both esmChunkedMain blocks have correct hook registration order', () => { + const esmChunkedMarker = 'async function esmChunkedMain()'; + let blockCount = 0; + let offset = 0; + + while ((offset = script.indexOf(esmChunkedMarker, offset)) !== -1) { + blockCount++; + const blockStart = offset; + const blockEnd = script.indexOf('\nasync function', offset + 1); + const actualBlockEnd = blockEnd !== -1 ? blockEnd : script.length; + const block = script.slice(blockStart, actualBlockEnd); + + // Find the three key operations + const initIdx = block.indexOf('loaderMod.initialize({'); + const interceptIdx = block.indexOf('loaderMod.installFsBunfsInterception()'); + const registerIdx = block.indexOf('registerHooks({'); + + assert.ok(initIdx !== -1, `Block ${blockCount}: missing initialize call`); + assert.ok(interceptIdx !== -1, `Block ${blockCount}: missing installFsBunfsInterception call`); + assert.ok(registerIdx !== -1, `Block ${blockCount}: missing registerHooks call`); + + // Verify order: initialize < installFsBunfsInterception < registerHooks + assert.ok( + initIdx < interceptIdx && interceptIdx < registerIdx, + `Block ${blockCount}: initialization order incorrect (initialize=${initIdx}, intercept=${interceptIdx}, register=${registerIdx})`, + ); + + offset = actualBlockEnd; + } + + assert.ok(blockCount >= 2, 'should have at least 2 esmChunkedMain blocks'); +}); + +test('termux-run-claude-native.sh maintains compatibility with new zstd fields', () => { + // Verify that the script structure is preserved + const hasSourceBin = script.includes('SOURCE_BIN='); + const hasWorkdir = script.includes('WORKDIR='); + const hasGlobalThis = script.includes('globalThis'); + + assert.ok(hasSourceBin, 'script should set SOURCE_BIN'); + assert.ok(hasWorkdir, 'script should set WORKDIR'); + assert.ok(hasGlobalThis, 'script should manipulate globalThis'); + + // Verify both blocks exist and are distinct + const blocks = (script.match(/globalThis\.Bun\s*=\s*{/g) || []); + assert.ok(blocks.length >= 2, 'should have at least 2 Bun initializations for helper and bootstrap'); +}); diff --git a/packages/claude-code/package.json b/packages/claude-code/package.json index 9d2ad5f..16c8aae 100644 --- a/packages/claude-code/package.json +++ b/packages/claude-code/package.json @@ -15,7 +15,7 @@ "LICENSE" ], "engines": { - "node": ">=22.15.0 <23.0.0 || >=23.5.0" + "node": ">=22.15.0 <23.0.0 || >=23.8.0" }, "keywords": [ "claude-code", From cc893d1f643c450ac6f8f497cdcce0ce25ab1e48 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Mon, 31 Aug 2026 15:16:05 +0900 Subject: [PATCH 2/2] test: strengthen syncBuiltinESMExports and rollback tests to actually exercise installFsBunfsInterception() G3 non-blocker fixes: the sync-necessity test previously hand-rolled a toy simulation instead of calling installFsBunfsInterception(), and the rollback test only wrote a subprocess script without ever executing it (spawnSync was never called). Both are now proper child-process integration tests against the real implementation, and both were verified to fail when the corresponding source behavior is deliberately broken. Co-Authored-By: Claude Sonnet 5 --- .../claude-code/lib/bunfs-esm-loader.test.js | 189 +++++++++++++----- 1 file changed, 139 insertions(+), 50 deletions(-) diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index 603db13..b1d7bd6 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -971,40 +971,72 @@ test('resolveBunfsPath: resolves valid /$bunfs/root/ paths correctly', async () }); test('syncBuiltinESMExports: fs interception requires it for ESM sync', async () => { + // installFsBunfsInterception() 自体を、G1/G4 で terra が実機確認した正しい順序 + // (ESM fixture を先に import してバインディング確定 → fs 差替え(sync前)は旧関数 → + // sync 後は新関数) で検証する。ハンドロールした模擬ではなく実装本体を子プロセスで実行する。 const { spawnSync } = require('node:child_process'); const tempDir = path.join(os.tmpdir(), `bunfs-sync-test-${process.pid}-${Date.now()}`); fs.mkdirSync(tempDir, { recursive: true }); + const loaderPath = path.join(__dirname, 'bunfs-esm-loader.mjs'); try { + const targetFile = path.join(tempDir, 'target.js'); + fs.writeFileSync(targetFile, 'export const marker = "REAL_CONTENT";'); + 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 {};'); + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary content'); + + // fixture.mjs: ESM named import を先に確立する側 (installFsBunfsInterception より前に + // dynamic import することで、バインディングが patch 前の状態で確定する) + const fixturePath = path.join(tempDir, 'fixture.mjs'); + fs.writeFileSync(fixturePath, ` +import { readFileSync } from 'node:fs'; +export function readIt(p) { return readFileSync(p, 'utf8'); } +`); + const testScript = path.join(tempDir, 'test-sync.mjs'); fs.writeFileSync(testScript, ` -import fs from 'node:fs'; -const origRead = fs.readFileSync; - -// ESM fixture - import it first -import { readFileSync as esm_read } from 'node:fs'; -console.log('ESM import done'); +const tempDir = ${JSON.stringify(tempDir)}; +const targetFile = ${JSON.stringify(targetFile)}; -// Replace fs.readFileSync without syncBuiltinESMExports -fs.readFileSync = () => 'replaced'; +// 1. ESM fixture を先に import (バインディングを patch 前の状態で確定させる) +const { readIt } = await import(${JSON.stringify(pathToFileURL(fixturePath).href)}); -// Try to call from ESM - should use old version -const result1 = esm_read; -console.log('Before sync: ' + (result1 === origRead ? 'original' : 'unknown')); +// 2. installFsBunfsInterception() 未適用の状態での素の読み込み確認 (対照) +const before = readIt(targetFile); +if (before !== 'export const marker = "REAL_CONTENT";') { + console.error('SETUP_FAILED: fixture cannot read target file before patch'); + process.exit(1); +} -// Now simulate syncBuiltinESMExports effect -const { syncBuiltinESMExports } = await import('node:module'); -syncBuiltinESMExports(); +// 3. fs を差し替える (installFsBunfsInterception 経由、syncBuiltinESMExports 込み) +const loader = await import(${JSON.stringify(pathToFileURL(loaderPath).href)}); +loader.initialize({ + processOwnedDir: tempDir, + sourceBin: ${JSON.stringify(sourceBin)}, + childProcessGuardPath: ${JSON.stringify(path.join(tempDir, 'guard.mjs'))}, + vmGuardPath: ${JSON.stringify(path.join(tempDir, 'vm-guard.mjs'))}, + wsStubPath: ${JSON.stringify(path.join(tempDir, 'ws-stub.mjs'))}, +}); +loader.installFsBunfsInterception(); -// Try to call again - should use new version -const { readFileSync: esm_read2 } = await import('node:fs'); -console.log('After sync: ' + (esm_read2 === fs.readFileSync ? 'replaced' : 'original')); +// 4. 先に確立した ESM バインディング経由で /$bunfs/root/ パスを読む +// → syncBuiltinESMExports() が正しく効いていれば、fixture の readFileSync も +// パッチ後の関数を参照し、bunfs パス解決が機能するはず +const afterViaBinding = readIt('/$bunfs/root/target.js'); +if (afterViaBinding !== 'export const marker = "REAL_CONTENT";') { + console.error('SYNC_FAILED: pre-bound ESM readFileSync did not pick up the fs interception patch'); + process.exit(1); +} +console.log('SYNC_OK'); +process.exit(0); `); const result = spawnSync('node', [testScript], { encoding: 'utf8' }); - assert.equal(result.status, 0, `Script failed: ${result.stderr}`); - assert.ok(result.stdout.includes('ESM import done')); - // The actual behavior depends on Node version, but the test structure proves the concept + assert.equal(result.status, 0, `Script failed (status=${result.status}): stdout=${result.stdout} stderr=${result.stderr}`); + assert.ok(result.stdout.includes('SYNC_OK'), `expected SYNC_OK marker, got: ${result.stdout}`); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -1185,51 +1217,108 @@ test('installFsBunfsInterception: idempotent (multiple calls are safe)', async ( } }); -test('fs interception: FS_PATCHED flag prevents double-patching', async () => { +test('fs interception: rollback on partial failure leaves FS_PATCHED false (retry succeeds)', async () => { + // G1で確定した設計: syncBuiltinESMExports() が失敗した場合、3関数を元に戻し + // FS_PATCHED は立てない。次回呼出しで再試行できることを外部挙動で証明する + // (private 変数を直接読まず、「1回目は throw して起こす失敗後、2回目の呼出しが + // 実際にパッチを完了する」ことで FS_PATCHED===false だったことを証明する)。 const { spawnSync } = require('node:child_process'); - const tempDir = path.join(os.tmpdir(), `bunfs-no-double-patch-${process.pid}-${Date.now()}`); + const tempDir = path.join(os.tmpdir(), `bunfs-rollback-${process.pid}-${Date.now()}`); fs.mkdirSync(tempDir, { recursive: true }); + const loaderPath = path.join(__dirname, 'bunfs-esm-loader.mjs'); try { - const testScript = path.join(tempDir, 'test-idempotent.mjs'); + const targetFile = path.join(tempDir, 'target.js'); + fs.writeFileSync(targetFile, 'export const marker = "REAL_CONTENT";'); + 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 {};'); + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary content'); + const normalFile = path.join(tempDir, 'normal.txt'); + fs.writeFileSync(normalFile, 'NORMAL_CONTENT'); + + const testScript = path.join(tempDir, 'test-rollback.mjs'); fs.writeFileSync(testScript, ` import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const loader = await import('./bunfs-esm-loader.mjs'); -const tempDir = process.argv[1]; -const testFile = require('node:path').join(tempDir, 'test.txt'); -require('node:fs').writeFileSync(testFile, 'test content'); +const tempDir = ${JSON.stringify(tempDir)}; +const normalFile = ${JSON.stringify(normalFile)}; +// ESM namespace ('node:module' の import 経由の名前空間) は読み取り専用のため、 +// installFsBunfsInterception 自体と同じく CJS 側 (require) を可変対象として差し替える。 +const nodeModuleCjs = require('node:module'); +const origFsMod = require('node:fs'); +const origReadFileSync = origFsMod.readFileSync; +const origReadFile = origFsMod.readFile; +const origPromisesReadFile = origFsMod.promises.readFile; +const realSync = nodeModuleCjs.syncBuiltinESMExports; + +// 1回目のみ throw するモックへ差し替え。差し替え自体を ESM 側にも伝播させるため +// 一度だけ本物の syncBuiltinESMExports を呼んでおく (この呼出し自体は失敗しない)。 +let syncCallCount = 0; +nodeModuleCjs.syncBuiltinESMExports = () => { + syncCallCount++; + if (syncCallCount === 1) { + throw new Error('forced syncBuiltinESMExports failure (test)'); + } + return realSync(); +}; +realSync(); +const loader = await import(${JSON.stringify(pathToFileURL(loaderPath).href)}); loader.initialize({ processOwnedDir: tempDir, - sourceBin: '/dummy/bin', - childProcessGuardPath: require('node:path').join(tempDir, 'guard.mjs'), - vmGuardPath: require('node:path').join(tempDir, 'vm-guard.mjs'), - wsStubPath: require('node:path').join(tempDir, 'ws-stub.mjs'), + sourceBin: ${JSON.stringify(sourceBin)}, + childProcessGuardPath: ${JSON.stringify(path.join(tempDir, 'guard.mjs'))}, + vmGuardPath: ${JSON.stringify(path.join(tempDir, 'vm-guard.mjs'))}, + wsStubPath: ${JSON.stringify(path.join(tempDir, 'ws-stub.mjs'))}, }); -// Create dummy guard files -require('node:fs').writeFileSync(require('node:path').join(tempDir, 'guard.mjs'), 'export default {}'); -require('node:fs').writeFileSync(require('node:path').join(tempDir, 'vm-guard.mjs'), 'export default {}'); -require('node:fs').writeFileSync(require('node:path').join(tempDir, 'ws-stub.mjs'), 'export default {}'); - -// Apply interception twice -loader.installFsBunfsInterception(); -loader.installFsBunfsInterception(); - -// Read normal file twice - should work both times -const fs = require('node:fs'); -const content1 = fs.readFileSync(testFile, 'utf8'); -const content2 = fs.readFileSync(testFile, 'utf8'); - -console.log('Content1:', content1); -console.log('Content2:', content2); -console.log('Match:', content1 === content2); +// 1回目: syncBuiltinESMExports が throw するため installFsBunfsInterception も throw するはず +let firstThrew = false; +try { + loader.installFsBunfsInterception(); +} catch (e) { + firstThrew = true; +} +if (!firstThrew) { + console.error('EXPECTED_THROW_MISSING: first installFsBunfsInterception() call did not throw'); + process.exit(1); +} + +// ロールバック確認: 3関数が元の参照に戻っているか (通常ファイル読み込みが正常動作することで確認) +const fsMod = require('node:fs'); +if (fsMod.readFileSync !== origReadFileSync || fsMod.readFile !== origReadFile || fsMod.promises.readFile !== origPromisesReadFile) { + console.error('ROLLBACK_FAILED: fs functions were not restored to originals after failure'); + process.exit(1); +} +const normalContent = fsMod.readFileSync(normalFile, 'utf8'); +if (normalContent !== 'NORMAL_CONTENT') { + console.error('ROLLBACK_BROKEN_READ: normal file read broken after rollback'); + process.exit(1); +} + +// 2回目: モックは以後成功するため、FS_PATCHED が false のままなら今度は成功するはず +let secondThrew = false; +try { + loader.installFsBunfsInterception(); +} catch (e) { + secondThrew = true; + console.error('SECOND_CALL_THREW: ' + e.message); +} +if (secondThrew) { + console.error('FS_PATCHED_STUCK_TRUE_OR_RETRY_BLOCKED: second call should have succeeded'); + process.exit(1); +} + +console.log('ROLLBACK_AND_RETRY_OK'); +process.exit(0); `); - // This test would require resolving import paths in the subprocess - // For now, we verify the idempotency through the sync test above + const result = spawnSync('node', [testScript], { encoding: 'utf8' }); + assert.equal(result.status, 0, `Script failed (status=${result.status}): stdout=${result.stdout} stderr=${result.stderr}`); + assert.ok(result.stdout.includes('ROLLBACK_AND_RETRY_OK'), `expected ROLLBACK_AND_RETRY_OK marker, got: stdout=${result.stdout} stderr=${result.stderr}`); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }