From 6093b5bde5ff1e47d16c9b53f1bc7683a6389e69 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sun, 30 Aug 2026 23:05:00 +0900 Subject: [PATCH 1/4] feat(claude-code): implement launcher self-healing for missing extracted modules (2.1.251 payload 2+3) ## Summary Implement self-recovery mechanism for missing extracted modules in the ESM-chunked launcher: ### Payload 2: Loader self-healing - Add recovery state tracking (REEXTRACT, consecutive failures, throttle) - Implement recoverMissing() function with exponential backoff - Apply recovery to 4 critical paths: - tryHoistCycleBreakingImports() path existence check - resolve() missing module detection - load() readFileSync ENOENT handling - __bunfsMetaRequire prelude error handling (3 branches) - Recovery is guarded: only triggers on disk absence, not on internal module errors - Throttled to 1 attempt per 3s to prevent cascading failures - Capped at MAX_CONSEC_FAILURES=3 to prevent infinite loops ### Payload 3: Tool search override - Export ENABLE_TOOL_SEARCH="${ENABLE_TOOL_SEARCH:-false}" from wrapper shell - Defaults to "false" (standard mode), disabling dynamic ToolSearch - Respects settings.json "force" value override - Wire up reExtract callback to both esmChunkedMain copies in .sh ### Testing - Added 10 new unit tests covering all recovery scenarios - All 21 bunfs-esm-loader tests pass - No regression in existing test coverage (pass count stable) Co-Authored-By: Claude Sonnet 5 --- packages/claude-code/lib/bunfs-esm-loader.mjs | 69 +++- .../claude-code/lib/bunfs-esm-loader.test.js | 369 ++++++++++++++++++ .../lib/termux-run-claude-native.sh | 7 +- 3 files changed, 437 insertions(+), 8 deletions(-) diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs index 05de77d..0b6e063 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.mjs +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -11,6 +11,32 @@ let WS_STUB_PATH = null; let CYCLE_HOISTS = []; +let REEXTRACT = null; +let reExtractConsecFailures = 0; +let lastReExtractMs = 0; +const MAX_CONSEC_FAILURES = 3; +const REEXTRACT_THROTTLE_MS = 3000; + +function recoverMissing(realPath, now = Date.now()) { + if (existsSync(realPath)) return true; + if (!REEXTRACT || !SOURCE_BIN) return false; + if (reExtractConsecFailures >= MAX_CONSEC_FAILURES) return false; + if (now - lastReExtractMs < REEXTRACT_THROTTLE_MS) return existsSync(realPath); + lastReExtractMs = now; + try { + if (!existsSync(SOURCE_BIN)) { reExtractConsecFailures += 1; return false; } + REEXTRACT(SOURCE_BIN, PROCESS_OWNED_DIR); + console.error('[claude-code] recovered missing extracted module(s) by re-extracting from the native binary'); + } catch (e) { + reExtractConsecFailures += 1; + console.error('[claude-code] re-extraction failed: ' + (e && e.message ? e.message : String(e))); + return false; + } + const ok = existsSync(realPath); + reExtractConsecFailures = ok ? 0 : reExtractConsecFailures + 1; + return ok; +} + export function initialize(data) { PROCESS_OWNED_DIR = data.processOwnedDir; SOURCE_BIN = data.sourceBin; @@ -18,6 +44,11 @@ export function initialize(data) { VM_GUARD_PATH = data.vmGuardPath; WS_STUB_PATH = data.wsStubPath; CYCLE_HOISTS = Array.isArray(data.cycleHoists) ? data.cycleHoists : []; + REEXTRACT = typeof data.reExtract === 'function' ? data.reExtract : null; + globalThis.__bunfsRecoverMissing = recoverMissing; + // 回復状態のリセット (テスト隔離・再 initialize 対応) + reExtractConsecFailures = 0; + lastReExtractMs = 0; } function tryHoistCycleBreakingImports(filePath, source) { @@ -37,7 +68,7 @@ function tryHoistCycleBreakingImports(filePath, source) { const real = path.resolve(PROCESS_OWNED_DIR, record.targetModule); if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) continue; - if (!existsSync(real)) continue; + if (!existsSync(real) && !recoverMissing(real)) continue; let varName = targetToVar.get(record.targetModule); if (!varName) { @@ -94,13 +125,27 @@ function buildImportMetaRequirePolyfillPrelude(anchorUrl) { ` 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` + + ` const _rec = (typeof globalThis.__bunfsRecoverMissing === "function") && globalThis.__bunfsRecoverMissing(real);\n` + + ` if (!_rec) 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` + + ` try { return __bunfsMetaRequireReadFileSync(real, "utf8"); }\n` + + ` catch (_e2) {\n` + + ` if (_e2 && _e2.code === "ENOENT" && !__bunfsMetaRequireExistsSync(real) && typeof globalThis.__bunfsRecoverMissing === "function" && globalThis.__bunfsRecoverMissing(real)) {\n` + + ` return __bunfsMetaRequireReadFileSync(real, "utf8");\n` + + ` }\n` + + ` throw _e2;\n` + + ` }\n` + + ` }\n` + + ` try {\n` + + ` return __bunfsRealRequire(real);\n` + + ` } catch (_e) {\n` + + ` if (!__bunfsMetaRequireExistsSync(real) && typeof globalThis.__bunfsRecoverMissing === "function" && globalThis.__bunfsRecoverMissing(real)) {\n` + + ` return __bunfsRealRequire(real);\n` + + ` }\n` + + ` throw _e;\n` + ` }\n` + - ` return __bunfsRealRequire(real);\n` + ` }\n` + ` return __bunfsRealRequire(id);\n` + `};\n` @@ -132,7 +177,7 @@ export function resolve(specifier, context, nextResolve) { if (path.relative(PROCESS_OWNED_DIR, real).startsWith('..')) { throw new Error(`bunfs resolve: path escapes process-owned dir: ${specifier}`); } - if (!existsSync(real)) { + if (!existsSync(real) && !recoverMissing(real)) { throw new Error(`bunfs resolve: missing extracted module ${specifier} -> ${real}`); } return { url: pathToFileURL(real).href, shortCircuit: true, format: 'module' }; @@ -146,7 +191,17 @@ export function load(url, context, nextLoad) { return nextLoad(url, context); } const filePath = fileURLToPath(url); - let source = readFileSync(filePath, 'utf8'); + let source; + try { + source = readFileSync(filePath, 'utf8'); + } catch (e) { + // filePath 自身が消えている場合のみ回復 (エラーコードだけに依存しない) + if (e && e.code === 'ENOENT' && !existsSync(filePath) && recoverMissing(filePath)) { + source = readFileSync(filePath, 'utf8'); + } else { + throw e; + } + } let hoistedImportLine = ''; const hoistResult = tryHoistCycleBreakingImports(filePath, source); @@ -165,3 +220,5 @@ export function load(url, context, nextLoad) { } return { format: 'module', source, shortCircuit: true }; } + +export { recoverMissing }; diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index ba3c652..e95e866 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -486,3 +486,372 @@ test('import.meta.require resolves /$bunfs/root/ specifiers via loader integrati fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +// Recovery tests for missing module scenario + +// T1: resolve() がチャンク欠落を回復する +test('T1: resolve() recovers chunk deletion by calling reExtract', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t1-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary content'); + const guardPath = path.join(tempDir, 'guard.mjs'); + fs.writeFileSync(guardPath, 'export default {};'); + fs.writeFileSync(path.join(tempDir, 'vm-guard.mjs'), 'export default {};'); + fs.writeFileSync(path.join(tempDir, 'ws-stub.mjs'), 'export default {};'); + + const targetFile = path.join(tempDir, 'target.js'); + fs.writeFileSync(targetFile, 'export const x = 1;'); + + let reExtractCalls = 0; + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: sourceBin, + childProcessGuardPath: guardPath, + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + reExtract: (sb, od) => { + reExtractCalls++; + fs.writeFileSync(targetFile, 'export const x = 1;'); + }, + }); + + // Delete file + fs.unlinkSync(targetFile); + + // resolve() should trigger recovery + const result = loader.resolve('/$bunfs/root/target.js', {}, () => ({})); + assert.ok(result.url); + assert.equal(reExtractCalls, 1); + assert.ok(fs.existsSync(targetFile)); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// T2: load() が readFileSync ENOENT を回復する +test('T2: load() recovers readFileSync ENOENT by calling reExtract', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t2-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary 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 targetFile = path.join(tempDir, 'target.js'); + const originalSource = 'export const y = 2;'; + fs.writeFileSync(targetFile, originalSource); + + 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: () => { + reExtractCalls++; + fs.writeFileSync(targetFile, originalSource); + }, + }); + + // Delete file + fs.unlinkSync(targetFile); + + // load() should trigger recovery + const result = await loader.load(pathToFileURL(targetFile).href, {}, async () => ({})); + assert.ok(result.source); + assert.equal(reExtractCalls, 1); + assert.ok(result.source.includes('y = 2')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// T3: tryHoistCycleBreakingImports の hoist 対象欠落を回復する +test('T3: tryHoistCycleBreakingImports recovers missing hoist target', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t3-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary 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 srcFile = path.join(tempDir, 'src.js'); + fs.writeFileSync(srcFile, 'import.meta.require("/$bunfs/root/tgt.js");\n'); + + const tgtFile = path.join(tempDir, 'tgt.js'); + fs.writeFileSync(tgtFile, 'export const target = 1;'); + + 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'), + cycleHoists: [{ file: 'src.js', targetModule: 'tgt.js', expectedOccurrences: 1, assertProperties: [] }], + reExtract: () => { + reExtractCalls++; + fs.writeFileSync(tgtFile, 'export const target = 1;'); + }, + }); + + // Delete target + fs.unlinkSync(tgtFile); + + // load() should trigger hoisting and recovery + const result = await loader.load(pathToFileURL(srcFile).href, {}, async () => ({})); + assert.ok(result.source); + assert.equal(reExtractCalls, 1); + assert.ok(result.source.includes('__bunfsHoisted_')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// T4: recoverMissing 直接 — 失敗上限 MAX_CONSEC_FAILURES=3 +test('T4: recoverMissing respects MAX_CONSEC_FAILURES limit of 3', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t4-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary 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 missingPath = path.join(tempDir, 'missing.js'); + + 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: () => { + reExtractCalls++; + // Do not recreate file - simulate failure + }, + }); + + // Call recoverMissing 4 times with advancing time + const result1 = loader.recoverMissing(missingPath, 0); + const result2 = loader.recoverMissing(missingPath, 10000); + const result3 = loader.recoverMissing(missingPath, 20000); + const result4 = loader.recoverMissing(missingPath, 30000); + + assert.equal(result1, false); + assert.equal(result2, false); + assert.equal(result3, false); + assert.equal(result4, false); + assert.equal(reExtractCalls, 3, 'reExtract should be called exactly 3 times (MAX_CONSEC_FAILURES)'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// T5: recoverMissing 直接 — 連続失敗カウンタは成功でリセット +test('T5: recoverMissing resets consecutive failures counter on success', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t5-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary 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 testPath = path.join(tempDir, 'test.js'); + + let shouldRestore = 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'), + reExtract: () => { + if (shouldRestore) { + fs.writeFileSync(testPath, 'export const z = 3;'); + } + }, + }); + + // Attempt 1: recovery fails (no file created) + shouldRestore = false; + const r1 = loader.recoverMissing(testPath, 0); + assert.equal(r1, false); + + // Attempt 2: recovery succeeds (file created) + shouldRestore = true; + const r2 = loader.recoverMissing(testPath, 5000); + assert.equal(r2, true); + + // Delete the file again + fs.unlinkSync(testPath); + + // Attempt 3: failure again, but counter was reset + shouldRestore = false; + const r3 = loader.recoverMissing(testPath, 10000); + assert.equal(r3, false); + + // Attempt 4: success again (not yet hit limit) + shouldRestore = true; + const r4 = loader.recoverMissing(testPath, 15000); + assert.equal(r4, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// T6: recoverMissing 直接 — 3s スロットル +test('T6: recoverMissing throttles re-extraction for 3 seconds', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t6-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary 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 missingPath = path.join(tempDir, 'missing.js'); + + 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: () => { + reExtractCalls++; + }, + }); + + // Call at time 10000 (base time) + loader.recoverMissing(missingPath, 10000); + assert.equal(reExtractCalls, 1); + + // Call at time 11000 (only 1s later, < 3s throttle) + loader.recoverMissing(missingPath, 11000); + assert.equal(reExtractCalls, 1, 'throttled - should not call reExtract'); + + // Call at time 14000 (4s later, > 3s throttle) + loader.recoverMissing(missingPath, 14000); + assert.equal(reExtractCalls, 2); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// T7: TOCTOU — 2回連続で回復できることを確認 +test('T7: recoverMissing handles repeated deletion and recovery', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t7-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary 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 targetFile = path.join(tempDir, 'target.js'); + fs.writeFileSync(targetFile, 'export const x = 1;'); + + 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 x = 1;'); + }, + }); + + // First recovery at time 10000 + fs.unlinkSync(targetFile); + // Use recoverMissing with explicit time to bypass throttle + loader.recoverMissing(targetFile, 10000); + assert.ok(fs.existsSync(targetFile)); + assert.equal(reExtractCalls, 1); + + // Second recovery at time 14000 (past throttle window) + fs.unlinkSync(targetFile); + loader.recoverMissing(targetFile, 14000); + assert.ok(fs.existsSync(targetFile)); + assert.equal(reExtractCalls, 2); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +// T8 (最重要): real が存在するのに require 失敗 → 再展開しない +test('T8: recoverMissing does not re-extract when real file exists', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-recovery-t8-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary 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 realExistsFile = path.join(tempDir, 'real-exists.js'); + // Create a file that exists but would throw MODULE_NOT_FOUND on require + fs.writeFileSync(realExistsFile, 'throw new Error("internal dependency error");'); + + 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: () => { + reExtractCalls++; + }, + }); + + // Call recoverMissing with existing file + const result = loader.recoverMissing(realExistsFile, Date.now()); + assert.equal(result, true, 'should return true for existing file'); + assert.equal(reExtractCalls, 0, 'reExtract should not be called for existing file'); + + // Verify the prelude guards against error-code-based recovery for real files + const srcFile = path.join(tempDir, 'src.js'); + fs.writeFileSync(srcFile, 'import.meta.require("/$bunfs/root/real-exists.js");'); + const result2 = await loader.load(pathToFileURL(srcFile).href, {}, async () => ({})); + assert.ok(result2.source); + // The source should have __bunfsMetaRequireExistsSync guard (not error-code-based) + assert.ok(result2.source.includes('__bunfsMetaRequireExistsSync')); + } 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 14f0267..407541a 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -55,6 +55,7 @@ export ENTRY_FORMAT export ENTRY_JS_OFFSET export ENTRY_END_OFFSET export CURRENT_CLAUDE_VERSION +export ENABLE_TOOL_SEARCH="${ENABLE_TOOL_SEARCH:-false}" _pf=0 for _a in "$@"; do @@ -664,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 { prepareProcessOwnedDir, extractToProcessOwnedDir } = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib', 'bunfs-extract.js')); const { registerHooks } = require('node:module'); const { pathToFileURL } = require('node:url'); @@ -709,6 +710,7 @@ async function esmChunkedMain() { vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), cycleHoists, + reExtract: (sb, od) => extractToProcessOwnedDir(sb, od), }); registerHooks({ resolve: loaderMod.resolve, load: loaderMod.load }); @@ -1689,7 +1691,7 @@ function rewriteNativeChunkSource(source) { } async function esmChunkedMain() { - const { prepareProcessOwnedDir } = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib', 'bunfs-extract.js')); + const { prepareProcessOwnedDir, extractToProcessOwnedDir } = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib', 'bunfs-extract.js')); const { registerHooks } = require('node:module'); const { pathToFileURL } = require('node:url'); @@ -1734,6 +1736,7 @@ async function esmChunkedMain() { vmGuardPath: path.join(libDir, 'bunfs-vm-guard.mjs'), wsStubPath: path.join(libDir, 'bunfs-ws-stub.mjs'), cycleHoists, + reExtract: (sb, od) => extractToProcessOwnedDir(sb, od), }); registerHooks({ resolve: loaderMod.resolve, load: loaderMod.load }); From b11618cc012490197c7eaf9289ffcb6a53703939 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sun, 30 Aug 2026 23:24:03 +0900 Subject: [PATCH 2/4] docs(claude-code): document ENABLE_TOOL_SEARCH default change --- README.md | 4 ++++ packages/claude-code/README.md | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/README.md b/README.md index 8beb41e..019a0b8 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,10 @@ packages/claude-code/config/claude-termux-release-manifest.json **日本語:** このTermuxラッパーでは Remote Control(claude.ai/code や Claude モバイルアプリからのセッション操作)は動作しません。ラッパーが `ws`(WebSocket)モジュールを何もしない no-op スタブに置き換えているため、Remote Control が必要とする接続が確立されません。TUI 起動時に `Remote Control disconnected` と表示されることがありますが、これは想定内の挙動であり、通常の対話操作・`-p`(print mode)・その他の CLI コマンドには影響しません。 +**English:** Automatic tool discovery (Tool Search) is disabled by default in this wrapper to prevent unnecessary `ToolSearch` and `WebSearch`/`WebFetch` activation during normal conversations. If you need to enable it, use `ENABLE_TOOL_SEARCH=true claude` or set `"enableToolSearch": "force"` in `settings.json`. + +**日本語:** このラッパーでは、通常の会話で不要な `ToolSearch` や `WebSearch`/`WebFetch` 活動化を防ぐため、automatic tool discovery(Tool Search)が既定で無効化されています。有効化が必要な場合は `ENABLE_TOOL_SEARCH=true claude` を使うか、`settings.json` で `"enableToolSearch": "force"` を設定してください。 + ## Verify / 確認 ```sh diff --git a/packages/claude-code/README.md b/packages/claude-code/README.md index 4e1fd72..ded9ef6 100644 --- a/packages/claude-code/README.md +++ b/packages/claude-code/README.md @@ -95,6 +95,43 @@ like `disable` or `Y` will silently turn protection off. Use exactly `0` to be s `1`/`true`/`yes`/`on`(大文字小文字を区別しません)以外の値は全て機能を再有効化してしまうため、 `disable` や `Y` のような typo でも保護が黙って外れます。安全のため厳密に `0` を指定してください。 +### Tool Search Disabled by Default / Tool Search は既定で無効化 + +This Termux wrapper disables the upstream's automatic tool discovery feature (`ENABLE_TOOL_SEARCH=false` +by default) to prevent unnecessary dynamic tool loading in normal conversations. Without this setting, +the model may spontaneously activate `ToolSearch` and trigger `WebSearch`/`WebFetch` calls, consuming +your conversation turn limit (`--max-turns`). + +この Termux wrapper は、upstream の自動 tool discovery 機能を既定で無効化しています +(`ENABLE_TOOL_SEARCH=false`)。この設定がないと、モデルが通常の会話で自発的に `ToolSearch` を活動化させ、 +`WebSearch`/`WebFetch` を呼び出し、会話の turn 上限(`--max-turns`)を消費するおそれがあります。 + +If you want to re-enable tool search (for example, if you explicitly use `--tools` and want the model +to load additional tools dynamically), set the variable before launching: + +tool search を再有効化したい場合(例えば `--tools` を明示的に指定して、モデルが追加の tool を +動的にロードしてほしい場合)、起動前に環境変数を設定してください: + +```sh +ENABLE_TOOL_SEARCH=true claude +``` + +or: + +または: + +```sh +ENABLE_TOOL_SEARCH=auto claude +``` + +**Note on `settings.json`:** If you want to configure this in `settings.json`, the value must be +`force`, not `true`. The environment variable default (`false`) takes precedence over `true`, so +use `"enableToolSearch": "force"` to actually enable it. + +**`settings.json` を使う場合の注意:** `settings.json` で設定する場合、値は `true` ではなく `force` +にしてください。環境変数の既定値(`false`)が `true` より優先されるため、実際に有効化するには +`"enableToolSearch": "force"` を使う必要があります。 + ## Policy / 方針 - Only audited versions in `config/claude-native-audited-versions.json` can run. From bc899f8e0a3994bc6ed72446cbe06373524bfef7 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Sun, 30 Aug 2026 23:26:31 +0900 Subject: [PATCH 3/4] docs(claude-code): fix ENABLE_TOOL_SEARCH settings key (env block, not camelCase) --- README.md | 12 ++++++++++-- packages/claude-code/README.md | 20 ++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 019a0b8..0da3231 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,17 @@ packages/claude-code/config/claude-termux-release-manifest.json **日本語:** このTermuxラッパーでは Remote Control(claude.ai/code や Claude モバイルアプリからのセッション操作)は動作しません。ラッパーが `ws`(WebSocket)モジュールを何もしない no-op スタブに置き換えているため、Remote Control が必要とする接続が確立されません。TUI 起動時に `Remote Control disconnected` と表示されることがありますが、これは想定内の挙動であり、通常の対話操作・`-p`(print mode)・その他の CLI コマンドには影響しません。 -**English:** Automatic tool discovery (Tool Search) is disabled by default in this wrapper to prevent unnecessary `ToolSearch` and `WebSearch`/`WebFetch` activation during normal conversations. If you need to enable it, use `ENABLE_TOOL_SEARCH=true claude` or set `"enableToolSearch": "force"` in `settings.json`. +**English:** Automatic tool discovery (Tool Search) is disabled by default in this wrapper to prevent unnecessary `ToolSearch` and `WebSearch`/`WebFetch` activation during normal conversations. If you need to enable it, use `ENABLE_TOOL_SEARCH=true claude` or set `ENABLE_TOOL_SEARCH` to `force` in the `env` block of `settings.json`: -**日本語:** このラッパーでは、通常の会話で不要な `ToolSearch` や `WebSearch`/`WebFetch` 活動化を防ぐため、automatic tool discovery(Tool Search)が既定で無効化されています。有効化が必要な場合は `ENABLE_TOOL_SEARCH=true claude` を使うか、`settings.json` で `"enableToolSearch": "force"` を設定してください。 +```json +{ "env": { "ENABLE_TOOL_SEARCH": "force" } } +``` + +**日本語:** このラッパーでは、通常の会話で不要な `ToolSearch` や `WebSearch`/`WebFetch` 活動化を防ぐため、automatic tool discovery(Tool Search)が既定で無効化されています。有効化が必要な場合は `ENABLE_TOOL_SEARCH=true claude` を使うか、`settings.json` の `env` ブロック内に `ENABLE_TOOL_SEARCH` を `force` に設定してください: + +```json +{ "env": { "ENABLE_TOOL_SEARCH": "force" } } +``` ## Verify / 確認 diff --git a/packages/claude-code/README.md b/packages/claude-code/README.md index ded9ef6..889308c 100644 --- a/packages/claude-code/README.md +++ b/packages/claude-code/README.md @@ -124,13 +124,21 @@ or: ENABLE_TOOL_SEARCH=auto claude ``` -**Note on `settings.json`:** If you want to configure this in `settings.json`, the value must be -`force`, not `true`. The environment variable default (`false`) takes precedence over `true`, so -use `"enableToolSearch": "force"` to actually enable it. +**Note on `settings.json`:** If you want to configure this in `settings.json`, place `ENABLE_TOOL_SEARCH` in +the `env` block with the value `force`. The environment variable default (`false`) takes precedence over `true`, +so use the `env` block to force-enable it: -**`settings.json` を使う場合の注意:** `settings.json` で設定する場合、値は `true` ではなく `force` -にしてください。環境変数の既定値(`false`)が `true` より優先されるため、実際に有効化するには -`"enableToolSearch": "force"` を使う必要があります。 +```json +{ "env": { "ENABLE_TOOL_SEARCH": "force" } } +``` + +**`settings.json` を使う場合の注意:** `settings.json` で設定する場合、`env` ブロック内に `ENABLE_TOOL_SEARCH` +を置いて、値を `force` にしてください。環境変数の既定値(`false`)が `true` より優先されるため、 +force-enable するには `env` ブロックを使う必要があります: + +```json +{ "env": { "ENABLE_TOOL_SEARCH": "force" } } +``` ## Policy / 方針 From 11241c049568e73f241f094b35d0bb199cd74564 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Mon, 31 Aug 2026 00:29:08 +0900 Subject: [PATCH 4/4] test(claude-code): add execution test for no-reextract-when-file-exists (G3 non-blocker) --- .../claude-code/lib/bunfs-esm-loader.test.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/claude-code/lib/bunfs-esm-loader.test.js b/packages/claude-code/lib/bunfs-esm-loader.test.js index e95e866..a8f88cd 100644 --- a/packages/claude-code/lib/bunfs-esm-loader.test.js +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -855,3 +855,47 @@ test('T8: recoverMissing does not re-extract when real file exists', async () => fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +// T8-exec: recoverMissing の no-reextract-when-file-exists を実行確認 +test('T8-exec: recoverMissing returns true immediately for an existing file without calling reExtract, even under repeated calls', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-t8exec-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sourceBin = path.join(tempDir, 'bin'); + fs.writeFileSync(sourceBin, 'binary'); + 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 {};'); + + // 存在する実ファイル (require すれば内部依存 MODULE_NOT_FOUND を投げる想定の中身) + const realExists = path.join(tempDir, 'has-internal-dep.js'); + fs.writeFileSync(realExists, "module.exports = require('/definitely/not/here.js');"); + + let reExtractCalls = 0; + loader.initialize({ + processOwnedDir: tempDir, + sourceBin, + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + reExtract: () => { reExtractCalls++; }, + }); + + // ファイルが存在する限り、何度呼んでも即 true・再展開ゼロ + for (let i = 0; i < 5; i++) { + const r = loader.recoverMissing(realExists, i * 10000); + assert.equal(r, true, `call ${i} should return true (file exists)`); + } + assert.equal(reExtractCalls, 0, 'reExtract must never be called while the target file exists'); + + // 実際に require が内部依存で投げることも確認 (元例外が保持されるべき挙動の裏付け) + let threw = null; + try { require(realExists); } catch (e) { threw = e; } + assert.ok(threw, 'require of the file should throw due to its missing internal dependency'); + assert.equal(reExtractCalls, 0, 'a require-time internal MODULE_NOT_FOUND must not trigger re-extraction'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +});