From 91569eab2338bed422cf80e5ccf11dbbc8345b27 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Fri, 28 Aug 2026 05:09:01 +0900 Subject: [PATCH 1/2] feat: support claude-code 2.1.245 esm-chunked native format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream 2.1.245 switched from a single CJS-wrapped bundle to Bun code-splitting into ESM chunks (StandaloneModuleGraph container with 1387 modules). Add a parallel esm-chunked execution path that extracts modules to a process-owned directory and runs them under plain Node.js via a custom ESM loader (bunfs-esm-loader.mjs), with guards for child_process/vm access (bunfs-child-process-guard.mjs, bunfs-vm-guard.mjs) and a ws no-op stub matching the existing legacy-cjs behavior (bunfs-ws-stub.mjs). The legacy-cjs path (single-bundle versions) is unchanged; format is auto-detected from the entry module's content. Reviewed via G1 (design), G3 (coding review), G4 (verification) — all Go with no blockers. --- .gitignore | 1 + README.md | 6 + config/claude-native-audited-versions.json | 8 + config/claude-termux-release-manifest.json | 2 +- packages/claude-code/bin/claude | 12 +- .../claude-native-audited-versions.json | 8 + .../claude-termux-release-manifest.json | 2 +- .../lib/bunfs-child-process-guard.mjs | 18 + .../lib/bunfs-child-process-guard.test.js | 113 +++++ packages/claude-code/lib/bunfs-esm-loader.mjs | 74 +++ .../claude-code/lib/bunfs-esm-loader.test.js | 329 +++++++++++++ packages/claude-code/lib/bunfs-extract.js | 205 ++++++++ .../claude-code/lib/bunfs-extract.test.js | 265 +++++++++++ packages/claude-code/lib/bunfs-vm-guard.mjs | 116 +++++ .../claude-code/lib/bunfs-vm-guard.test.js | 142 ++++++ packages/claude-code/lib/bunfs-ws-stub.mjs | 11 + packages/claude-code/lib/bunfs-yaml-shim.mjs | 91 ++++ packages/claude-code/lib/prepare-native.js | 30 ++ .../lib/termux-run-claude-native.sh | 136 +++++- .../lib/termux-run-claude-native.test.js | 6 +- packages/claude-code/package.json | 4 +- scripts/add-candidate-metadata.js | 20 +- .../termux-prepare-claude-native-version.js | 82 +++- scripts/verify-claude-native-e2e.sh | 437 ++++++++++++++++++ 24 files changed, 2089 insertions(+), 29 deletions(-) create mode 100644 packages/claude-code/lib/bunfs-child-process-guard.mjs create mode 100644 packages/claude-code/lib/bunfs-child-process-guard.test.js create mode 100644 packages/claude-code/lib/bunfs-esm-loader.mjs create mode 100644 packages/claude-code/lib/bunfs-esm-loader.test.js create mode 100644 packages/claude-code/lib/bunfs-extract.js create mode 100644 packages/claude-code/lib/bunfs-extract.test.js create mode 100644 packages/claude-code/lib/bunfs-vm-guard.mjs create mode 100644 packages/claude-code/lib/bunfs-vm-guard.test.js create mode 100644 packages/claude-code/lib/bunfs-ws-stub.mjs create mode 100644 packages/claude-code/lib/bunfs-yaml-shim.mjs create mode 100755 scripts/verify-claude-native-e2e.sh diff --git a/.gitignore b/.gitignore index aa1ec1e..4798e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.tgz +/.verify-*/ diff --git a/README.md b/README.md index b710af6..a30fec7 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,12 @@ config/claude-termux-release-manifest.json packages/claude-code/config/claude-termux-release-manifest.json ``` +## Known Limitations / 既知の制限 + +**English:** Remote Control (controlling a session from claude.ai/code or the Claude mobile app) does not work in this Termux wrapper. The wrapper replaces the `ws` (WebSocket) module with a no-op stub, so the underlying connection Remote Control needs is never established. The TUI may show `Remote Control disconnected` on startup; this is expected and does not affect normal interactive use, `-p` (print mode), or other CLI commands. + +**日本語:** このTermuxラッパーでは Remote Control(claude.ai/code や Claude モバイルアプリからのセッション操作)は動作しません。ラッパーが `ws`(WebSocket)モジュールを何もしない no-op スタブに置き換えているため、Remote Control が必要とする接続が確立されません。TUI 起動時に `Remote Control disconnected` と表示されることがありますが、これは想定内の挙動であり、通常の対話操作・`-p`(print mode)・その他の CLI コマンドには影響しません。 + ## Verify / 確認 ```sh diff --git a/config/claude-native-audited-versions.json b/config/claude-native-audited-versions.json index ab9b6d5..eb6edf5 100644 --- a/config/claude-native-audited-versions.json +++ b/config/claude-native-audited-versions.json @@ -770,6 +770,14 @@ "tarball_integrity": "sha512-tHsJhjwcoFyKTCQfYCclKjLtbnbbGKvcrzxUhMb6NDlPw9RrawmjSWUAdla7PtEVPaJsGkjGo4O2MQTlYIT+PA==", "tarball_sha256": "41de896e672667bc5b5060419d5a1ff5cfe15b77659d1418b10df5095e38419a", "status": "termux_verified" + }, + "2.1.245": { + "wrapper_spec": "@anthropic-ai/claude-code@2.1.245", + "native_spec": "@anthropic-ai/claude-code-linux-arm64@2.1.245", + "entry_format": "esm-chunked", + "tarball_integrity": "sha512-Qbn5HnZbYeW4GdifVkDGfcVKqj3/f3U9sfVd3LEaUZh08CcS8D/ptJ76zuBfQUGHJHfToAdNVfak86uJ84b1Dg==", + "tarball_sha256": "668662e7b5d91a93cff6c75736e60f3d5d3bed4cbe077ae4feefc63ad1253f4d", + "status": "offset_discovered" } } } diff --git a/config/claude-termux-release-manifest.json b/config/claude-termux-release-manifest.json index 06cee40..bc8dc6a 100644 --- a/config/claude-termux-release-manifest.json +++ b/config/claude-termux-release-manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "package_name": "@bash0816/claude-code", "latest_audited_version": "2.1.241", - "latest_candidate_version": "2.1.241", + "latest_candidate_version": "2.1.245", "previous_stable_version": "2.1.240", "stable_pinned_version": "2.1.220-2", "manifest_url": "https://raw.githubusercontent.com/bash0816/ClaudeCode-Termux/main/config/claude-termux-release-manifest.json" diff --git a/packages/claude-code/bin/claude b/packages/claude-code/bin/claude index 657e20e..5f3a8c6 100755 --- a/packages/claude-code/bin/claude +++ b/packages/claude-code/bin/claude @@ -42,11 +42,18 @@ process.stdout.write(String(item[field])); ' "${CONFIG_FILE}" "${CLAUDE_VERSION}" "$1" } -ENTRY_JS_OFFSET=$(json_field entry_js_offset) -ENTRY_END_OFFSET=$(json_field entry_end_offset) +ENTRY_FORMAT=$(json_field entry_format || echo "legacy-cjs") CACHE_DIR="${CLAUDE_TERMUX_PACKAGE_CACHE:-${HOME}/.claude-termux-native-package}/versions/${CLAUDE_VERSION}" SOURCE_BIN="${CACHE_DIR}/app/node_modules/@anthropic-ai/claude-code-linux-arm64/claude" +if [ "${ENTRY_FORMAT}" = "esm-chunked" ]; then + ENTRY_JS_OFFSET="" + ENTRY_END_OFFSET="" +else + ENTRY_JS_OFFSET=$(json_field entry_js_offset) + ENTRY_END_OFFSET=$(json_field entry_end_offset) +fi + if [ ! -f "${SOURCE_BIN}" ]; then "$NODE" "${PACKAGE_DIR}/lib/prepare-native.js" "${CLAUDE_VERSION}" fi @@ -58,6 +65,7 @@ fi SOURCE_BIN="${SOURCE_BIN}" \ WORKDIR="${CACHE_DIR}/launcher-workdir" \ +ENTRY_FORMAT="${ENTRY_FORMAT}" \ ENTRY_JS_OFFSET="${ENTRY_JS_OFFSET}" \ ENTRY_END_OFFSET="${ENTRY_END_OFFSET}" \ CURRENT_CLAUDE_VERSION="${CLAUDE_VERSION}" \ diff --git a/packages/claude-code/config/claude-native-audited-versions.json b/packages/claude-code/config/claude-native-audited-versions.json index ab9b6d5..eb6edf5 100644 --- a/packages/claude-code/config/claude-native-audited-versions.json +++ b/packages/claude-code/config/claude-native-audited-versions.json @@ -770,6 +770,14 @@ "tarball_integrity": "sha512-tHsJhjwcoFyKTCQfYCclKjLtbnbbGKvcrzxUhMb6NDlPw9RrawmjSWUAdla7PtEVPaJsGkjGo4O2MQTlYIT+PA==", "tarball_sha256": "41de896e672667bc5b5060419d5a1ff5cfe15b77659d1418b10df5095e38419a", "status": "termux_verified" + }, + "2.1.245": { + "wrapper_spec": "@anthropic-ai/claude-code@2.1.245", + "native_spec": "@anthropic-ai/claude-code-linux-arm64@2.1.245", + "entry_format": "esm-chunked", + "tarball_integrity": "sha512-Qbn5HnZbYeW4GdifVkDGfcVKqj3/f3U9sfVd3LEaUZh08CcS8D/ptJ76zuBfQUGHJHfToAdNVfak86uJ84b1Dg==", + "tarball_sha256": "668662e7b5d91a93cff6c75736e60f3d5d3bed4cbe077ae4feefc63ad1253f4d", + "status": "offset_discovered" } } } diff --git a/packages/claude-code/config/claude-termux-release-manifest.json b/packages/claude-code/config/claude-termux-release-manifest.json index 06cee40..bc8dc6a 100644 --- a/packages/claude-code/config/claude-termux-release-manifest.json +++ b/packages/claude-code/config/claude-termux-release-manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "package_name": "@bash0816/claude-code", "latest_audited_version": "2.1.241", - "latest_candidate_version": "2.1.241", + "latest_candidate_version": "2.1.245", "previous_stable_version": "2.1.240", "stable_pinned_version": "2.1.220-2", "manifest_url": "https://raw.githubusercontent.com/bash0816/ClaudeCode-Termux/main/config/claude-termux-release-manifest.json" diff --git a/packages/claude-code/lib/bunfs-child-process-guard.mjs b/packages/claude-code/lib/bunfs-child-process-guard.mjs new file mode 100644 index 0000000..a57ec9e --- /dev/null +++ b/packages/claude-code/lib/bunfs-child-process-guard.mjs @@ -0,0 +1,18 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const realChildProcess = require('node:child_process'); +const { createGuardedChildProcess } = require('./native-update-guard.js'); +const guarded = createGuardedChildProcess(realChildProcess, (v) => process.stderr.write(v)); + +export const spawn = guarded.spawn; +export const execFile = guarded.execFile; +export const exec = guarded.exec; +export const spawnSync = guarded.spawnSync; +export const execFileSync = guarded.execFileSync; +export const execSync = guarded.execSync; +export const ChildProcess = realChildProcess.ChildProcess; +export const fork = realChildProcess.fork; +export const _forkChild = realChildProcess._forkChild; + +export default Object.assign({}, realChildProcess, guarded); diff --git a/packages/claude-code/lib/bunfs-child-process-guard.test.js b/packages/claude-code/lib/bunfs-child-process-guard.test.js new file mode 100644 index 0000000..59fa49b --- /dev/null +++ b/packages/claude-code/lib/bunfs-child-process-guard.test.js @@ -0,0 +1,113 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +test('bunfs-child-process-guard exports named exports for guarded methods', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + + assert.equal(typeof guard.spawn, 'function'); + assert.equal(typeof guard.execFile, 'function'); + assert.equal(typeof guard.exec, 'function'); + assert.equal(typeof guard.spawnSync, 'function'); + assert.equal(typeof guard.execFileSync, 'function'); + assert.equal(typeof guard.execSync, 'function'); +}); + +test('bunfs-child-process-guard exports ChildProcess, fork, _forkChild', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + + // ChildProcess is a class + assert.equal(typeof guard.ChildProcess, 'function'); + // fork and _forkChild are functions + assert.equal(typeof guard.fork, 'function'); + assert.equal(typeof guard._forkChild, 'function'); +}); + +test('bunfs-child-process-guard provides default export with all methods', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + const defaultExport = guard.default; + + assert.ok(defaultExport); + assert.equal(typeof defaultExport, 'object'); + assert.equal(typeof defaultExport.spawn, 'function'); + assert.equal(typeof defaultExport.execFile, 'function'); + assert.equal(typeof defaultExport.exec, 'function'); + assert.equal(typeof defaultExport.spawnSync, 'function'); + assert.equal(typeof defaultExport.execFileSync, 'function'); + assert.equal(typeof defaultExport.execSync, 'function'); + assert.equal(typeof defaultExport.ChildProcess, 'function'); + assert.equal(typeof defaultExport.fork, 'function'); + assert.equal(typeof defaultExport._forkChild, 'function'); +}); + +test('bunfs-child-process-guard blocks official package update via execFileSync', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + + // Try to execute npm install of official package + try { + guard.execFileSync('npm', ['install', '-g', '@anthropic-ai/claude-code@latest']); + // If it doesn't throw, that's an error (should be blocked) + assert.fail('Expected execFileSync to block official package update'); + } catch (err) { + // Should throw with CLAUDE_TERMUX_OFFICIAL_UPDATE_BLOCKED code + assert.ok( + err.code === 'CLAUDE_TERMUX_OFFICIAL_UPDATE_BLOCKED' || + err.message.includes('disabled on Termux'), + `Expected block error, got: ${err.message}`, + ); + } +}); + +test('bunfs-child-process-guard blocks official package update via execSync', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + + // Try to execute npm install via exec + try { + guard.execSync('npm install -g @anthropic-ai/claude-code@latest'); + // If it doesn't throw, that's an error (should be blocked) + assert.fail('Expected execSync to block official package update'); + } catch (err) { + // Should throw with CLAUDE_TERMUX_OFFICIAL_UPDATE_BLOCKED code + assert.ok( + err.code === 'CLAUDE_TERMUX_OFFICIAL_UPDATE_BLOCKED' || + err.message.includes('disabled on Termux'), + `Expected block error, got: ${err.message}`, + ); + } +}); + +test('bunfs-child-process-guard allows harmless commands', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + + // echo is a harmless command and should not be blocked + const result = guard.execFileSync('echo', ['hello']); + // Verify the command actually executed and produced output + assert.equal(result.toString().trim(), 'hello'); +}); + +test('bunfs-child-process-guard spawn blocks official package install', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + + // spawn should return a blocked child process (EventEmitter-like) + const child = guard.spawn('npm', ['install', '-g', '@anthropic-ai/claude-code']); + + // Blocked spawn should have specific properties + assert.equal(child.stdout, null); + assert.equal(child.stderr, null); + assert.equal(child.stdin, null); + assert.equal(child.pid, 0); + assert.equal(child.killed, false); + + // Verify it's event-like (has on method or can be used as event emitter) + assert.equal(typeof child.kill, 'function'); +}); + +test('bunfs-child-process-guard does not block other package installs', async () => { + const guard = await import('./bunfs-child-process-guard.mjs'); + + // Installing a different package should not be blocked + const result = guard.execFileSync('echo', ['@bash0816/claude-code']); + // Verify the command actually executed and produced correct output + assert.equal(result.toString().trim(), '@bash0816/claude-code'); +}); diff --git a/packages/claude-code/lib/bunfs-esm-loader.mjs b/packages/claude-code/lib/bunfs-esm-loader.mjs new file mode 100644 index 0000000..1552dd8 --- /dev/null +++ b/packages/claude-code/lib/bunfs-esm-loader.mjs @@ -0,0 +1,74 @@ +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +let PROCESS_OWNED_DIR = null; +let SOURCE_BIN = null; +let CHILD_PROCESS_GUARD_PATH = null; +let VM_GUARD_PATH = null; +let WS_STUB_PATH = null; + +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; +} + +function buildImportMetaRequirePolyfillPrelude(anchorUrl) { + return ( + `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` + + `const __bunfsRealRequire = __bunfsCreateRequire(${JSON.stringify(anchorUrl)});\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` + + ` 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' }; + } + 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}`); + } + if (!existsSync(real)) { + throw new Error(`bunfs resolve: missing extracted module ${specifier} -> ${real}`); + } + return { url: pathToFileURL(real).href, shortCircuit: true, format: 'module' }; + } + return nextResolve(specifier, context); +} + +export async 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'); + if (source.includes('import.meta.require')) { + const anchorUrl = pathToFileURL(SOURCE_BIN).href; + source = buildImportMetaRequirePolyfillPrelude(anchorUrl) + + source.replaceAll('import.meta.require', '__bunfsMetaRequire'); + } + 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 new file mode 100644 index 0000000..b1a28c1 --- /dev/null +++ b/packages/claude-code/lib/bunfs-esm-loader.test.js @@ -0,0 +1,329 @@ +'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 { pathToFileURL } = require('node:url'); + +// ESM test ファイルから CommonJS で import できないため、 +// ここでは基本的な構造をテストする +test('bunfs-esm-loader module exports initialize, resolve, load functions', async () => { + // ESM モジュールを動的 import でテストする + const loader = await import('./bunfs-esm-loader.mjs'); + assert.equal(typeof loader.initialize, 'function'); + assert.equal(typeof loader.resolve, 'function'); + assert.equal(typeof loader.load, 'function'); +}); + +test('resolve() handles child_process and node:child_process specifiers', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const guardPath = path.join(tempDir, 'guard.mjs'); + fs.writeFileSync(guardPath, 'export default {};'); + + try { + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: '/dummy/bin', + childProcessGuardPath: guardPath, + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + }); + + const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + + // child_process should resolve to childProcessGuardPath + const result1 = await loader.resolve('child_process', {}, 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); + assert.ok(result2.url.includes(guardPath)); + assert.equal(result2.shortCircuit, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolve() handles vm and node:vm specifiers', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const vmGuardPath = path.join(tempDir, 'vm-guard.mjs'); + fs.writeFileSync(vmGuardPath, 'export default {};'); + + try { + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: '/dummy/bin', + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: vmGuardPath, + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + }); + + const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + + // vm should resolve to vmGuardPath + const result1 = await loader.resolve('vm', {}, 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); + assert.ok(result2.url.includes(vmGuardPath)); + assert.equal(result2.shortCircuit, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolve() handles ws specifier', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + const wsStubPath = path.join(tempDir, 'ws-stub.mjs'); + fs.writeFileSync(wsStubPath, 'export default {};'); + + try { + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: '/dummy/bin', + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: wsStubPath, + }); + + const nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + + const result = await loader.resolve('ws', {}, nextResolve); + assert.ok(result.url.includes(wsStubPath)); + assert.equal(result.shortCircuit, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolve() resolves /$bunfs/root/ specifiers to real files in processOwnedDir', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + // Create a dummy file in processOwnedDir + const dummyFile = path.join(tempDir, 'foo.js'); + fs.writeFileSync(dummyFile, 'export const foo = 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 nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + + const result = await loader.resolve('/$bunfs/root/foo.js', {}, nextResolve); + assert.ok(result.url.includes('foo.js')); + assert.equal(result.shortCircuit, true); + assert.equal(result.format, 'module'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolve() rejects path traversal with ..', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-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 nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + + await assert.rejects( + () => loader.resolve('/$bunfs/root/../../etc/passwd', {}, nextResolve), + /rejected specifier|escapes/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolve() rejects absolute paths', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-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 nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + + await assert.rejects( + () => loader.resolve('/$bunfs/root//etc/passwd', {}, nextResolve), + /rejected specifier|escapes/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolve() throws error for missing extracted module', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-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 nextResolve = async (spec, ctx) => ({ url: `unresolved:${spec}` }); + + await assert.rejects( + () => loader.resolve('/$bunfs/root/nonexistent.js', {}, nextResolve), + /missing extracted module/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('resolve() calls nextResolve for unknown specifiers', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-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'), + }); + + let nextResolveCalled = false; + const nextResolve = async (spec, ctx) => { + nextResolveCalled = true; + return { url: `unresolved:${spec}` }; + }; + + await loader.resolve('some-unknown-package', {}, nextResolve); + assert.equal(nextResolveCalled, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('load() returns source as-is when import.meta.require is not present', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + const testFile = path.join(tempDir, 'test.js'); + const sourceCode = 'export const x = 1;'; + fs.writeFileSync(testFile, sourceCode); + + try { + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: '/dummy/bin', + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + }); + + const fileUrl = pathToFileURL(testFile).href; + const result = await loader.load(fileUrl, {}, async () => ({ source: 'fallback' })); + + assert.equal(result.format, 'module'); + assert.equal(result.source, sourceCode); + assert.equal(result.shortCircuit, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('load() injects polyfill prelude when import.meta.require is present', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + const testFile = path.join(tempDir, 'test.js'); + const sourceCode = 'const cp = import.meta.require("child_process");'; + fs.writeFileSync(testFile, sourceCode); + + try { + loader.initialize({ + processOwnedDir: tempDir, + sourceBin: '/dummy/bin', + childProcessGuardPath: path.join(tempDir, 'guard.mjs'), + vmGuardPath: path.join(tempDir, 'vm-guard.mjs'), + wsStubPath: path.join(tempDir, 'ws-stub.mjs'), + }); + + const fileUrl = pathToFileURL(testFile).href; + const result = await loader.load(fileUrl, {}, async () => ({ source: 'fallback' })); + + assert.equal(result.format, 'module'); + assert.ok(result.source.includes('__bunfsMetaRequire')); + assert.ok(result.source.includes('import __bunfsGuardedChildProcess')); + assert.ok(result.source.includes('import __bunfsGuardedVm')); + // Check that import.meta.require was replaced with __bunfsMetaRequire + assert.ok(result.source.includes('__bunfsMetaRequire("child_process")')); + assert.ok(!result.source.includes('import.meta.require("child_process")')); + assert.equal(result.shortCircuit, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('load() calls nextLoad for URLs outside processOwnedDir', async () => { + const loader = await import('./bunfs-esm-loader.mjs'); + const tempDir = path.join(os.tmpdir(), `bunfs-esm-loader-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'), + }); + + let nextLoadCalled = false; + const nextLoad = async (url, ctx) => { + nextLoadCalled = true; + return { source: 'fallback', format: 'module' }; + }; + + await loader.load('file:///some/other/path/module.js', {}, nextLoad); + assert.equal(nextLoadCalled, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/claude-code/lib/bunfs-extract.js b/packages/claude-code/lib/bunfs-extract.js new file mode 100644 index 0000000..838442a --- /dev/null +++ b/packages/claude-code/lib/bunfs-extract.js @@ -0,0 +1,205 @@ +'use strict'; + +const { openSync, readSync, closeSync, fstatSync, mkdirSync, writeFileSync, readdirSync, statSync, rmSync } = require('node:fs'); +const path = require('node:path'); + +const TRAILER = Buffer.from('\n---- Bun! ----\n'); +const OFFSETS_STRUCT_SIZE = 32; +const MODULE_TABLE_ENTRY_SIZE = 52; +const SCAN_CHUNK_SIZE = 1024 * 1024; +const NAPI_LOADER = 10; + +function readRange(fd, offset, length) { + const buf = Buffer.alloc(length); + const bytesRead = readSync(fd, buf, 0, length, offset); + if (bytesRead !== length) { + throw new Error(`bunfs-extract: short read at offset ${offset} (expected ${length}, got ${bytesRead})`); + } + return buf; +} + +function findTrailerOffset(fd, fileSize) { + for (let end = fileSize; end > 0; end -= SCAN_CHUNK_SIZE) { + const start = Math.max(0, end - SCAN_CHUNK_SIZE - TRAILER.length); + const len = end - start; + const buf = readRange(fd, start, len); + const idx = buf.lastIndexOf(TRAILER); + if (idx >= 0) return start + idx; + } + throw new Error('bunfs-extract: StandaloneModuleGraph trailer not found'); +} + +function isSafeUint(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +function discoverModuleGraph(sourceBin) { + const fd = openSync(sourceBin, 'r'); + try { + const fileSize = fstatSync(fd).size; + const trailerOffset = findTrailerOffset(fd, fileSize); + + const offsetsStructStart = trailerOffset - OFFSETS_STRUCT_SIZE; + if (offsetsStructStart < 0) throw new Error('bunfs-extract: invalid trailer position'); + const offsetsBuf = readRange(fd, offsetsStructStart, OFFSETS_STRUCT_SIZE); + const byteCount = Number(offsetsBuf.readBigUInt64LE(0)); + const modulesOffset = offsetsBuf.readUInt32LE(8); + const modulesLength = offsetsBuf.readUInt32LE(12); + const entryPointId = offsetsBuf.readUInt32LE(16); + + if (!isSafeUint(byteCount) || !isSafeUint(modulesOffset) || !isSafeUint(modulesLength)) { + throw new Error('bunfs-extract: unsafe integer in Offsets struct'); + } + if (modulesLength % MODULE_TABLE_ENTRY_SIZE !== 0) { + throw new Error('bunfs-extract: module table length is not a multiple of entry size'); + } + + const dataStart = offsetsStructStart - byteCount; + if (dataStart < 0) throw new Error('bunfs-extract: computed dataStart is negative'); + + const numModules = modulesLength / MODULE_TABLE_ENTRY_SIZE; + if (numModules <= 0) throw new Error('bunfs-extract: module table is empty'); + if (!(entryPointId >= 0 && entryPointId < numModules)) { + throw new Error(`bunfs-extract: entry_point_id ${entryPointId} out of range (numModules=${numModules})`); + } + if (modulesOffset + modulesLength > byteCount) { + throw new Error('bunfs-extract: module table extends beyond byte_count'); + } + + const modTableBuf = readRange(fd, dataStart + modulesOffset, modulesLength); + + const modules = []; + const seenNames = new Set(); + let entryName = null; + let entryModule = null; + for (let i = 0; i < numModules; i += 1) { + const base = i * MODULE_TABLE_ENTRY_SIZE; + const nameOff = modTableBuf.readUInt32LE(base); + const nameLen = modTableBuf.readUInt32LE(base + 4); + const contOff = modTableBuf.readUInt32LE(base + 8); + const contLen = modTableBuf.readUInt32LE(base + 12); + const loader = modTableBuf[base + 49]; + + if (!isSafeUint(nameOff) || !isSafeUint(nameLen) || !isSafeUint(contOff) || !isSafeUint(contLen)) { + throw new Error(`bunfs-extract: unsafe integer in module table entry ${i}`); + } + if (nameOff + nameLen > byteCount) { + throw new Error(`bunfs-extract: module ${i} name range out of bounds`); + } + if (contOff + contLen > byteCount) { + throw new Error(`bunfs-extract: module ${i} content range out of bounds`); + } + + const name = readRange(fd, dataStart + nameOff, nameLen).toString('utf-8'); + if (seenNames.has(name)) { + throw new Error(`bunfs-extract: duplicate module name ${name}`); + } + seenNames.add(name); + + const absContOff = dataStart + contOff; + if (i === entryPointId) { + entryName = name; + entryModule = { name, contOff: absContOff, contLen }; + } + if (loader === NAPI_LOADER) continue; // ネイティブ.nodeバイナリは未使用、展開しない + if (contLen === 0) continue; + + modules.push({ name, contOff: absContOff, contLen }); + } + + if (entryName === null) throw new Error('bunfs-extract: entry module not found'); + + return { fd, modules, entryName, entryModule, numModules, byteCount }; + } catch (error) { + closeSync(fd); + throw error; + } +} + +function relPathFromModuleName(name) { + const rel = name.replace(/^\/\$bunfs\/root\//, ''); + if (rel.includes('..') || path.isAbsolute(rel)) { + throw new Error(`bunfs-extract: rejected unsafe module name ${name}`); + } + return rel; +} + +function extractToProcessOwnedDir(sourceBin, ownedDir) { + const graph = discoverModuleGraph(sourceBin); + const { fd, modules, entryName } = graph; + try { + mkdirSync(ownedDir, { recursive: true }); + for (const mod of modules) { + const rel = relPathFromModuleName(mod.name); + const outPath = path.resolve(ownedDir, rel); + if (path.relative(ownedDir, outPath).startsWith('..')) { + throw new Error(`bunfs-extract: path escapes owned dir: ${mod.name}`); + } + mkdirSync(path.dirname(outPath), { recursive: true }); + const content = readRange(fd, mod.contOff, mod.contLen); + writeFileSync(outPath, content); + } + } finally { + closeSync(fd); + } + return { entryRelPath: relPathFromModuleName(entryName) }; +} + +function cleanupStaleOwnedDirs(workdir, prefix, now = Date.now()) { + const maxAgeMs = 24 * 60 * 60 * 1000; + let entries; + try { + entries = readdirSync(workdir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.startsWith(prefix)) continue; + const dirPath = path.join(workdir, entry.name); + let stats; + try { + stats = statSync(dirPath); + } catch { + continue; + } + if (Number.isFinite(stats.mtimeMs) && now - stats.mtimeMs < maxAgeMs) continue; + + const pidMatch = entry.name.match(/^esm\.(\d+)\./); + if (pidMatch) { + const pid = Number(pidMatch[1]); + if (Number.isInteger(pid) && pid > 0) { + try { + process.kill(pid, 0); + continue; // ESRCH以外(プロセス生存中、またはEPERM等)は削除対象から除外 + } catch (error) { + if (error && error.code !== 'ESRCH') continue; + } + } + } + try { + rmSync(dirPath, { recursive: true, force: true }); + } catch {} + } +} + +function prepareProcessOwnedDir(sourceBin, workdir) { + const dirName = `esm.${process.pid}.${Date.now().toString(36)}.${Math.random().toString(36).slice(2)}.bare-dir`; + cleanupStaleOwnedDirs(workdir, 'esm.'); + const ownedDir = path.join(workdir, dirName); + const { entryRelPath } = extractToProcessOwnedDir(sourceBin, ownedDir); + return { ownedDir, entryRelPath }; +} + +function readEntryContentPrefix(fd, entryModule, maxLength = 256) { + const length = Math.min(entryModule.contLen, maxLength); + return readRange(fd, entryModule.contOff, length); +} + +module.exports = { + discoverModuleGraph, + extractToProcessOwnedDir, + cleanupStaleOwnedDirs, + prepareProcessOwnedDir, + readEntryContentPrefix, +}; diff --git a/packages/claude-code/lib/bunfs-extract.test.js b/packages/claude-code/lib/bunfs-extract.test.js new file mode 100644 index 0000000..d6ffb28 --- /dev/null +++ b/packages/claude-code/lib/bunfs-extract.test.js @@ -0,0 +1,265 @@ +'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 { + discoverModuleGraph, + extractToProcessOwnedDir, + cleanupStaleOwnedDirs, + prepareProcessOwnedDir, + readEntryContentPrefix, +} = 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(), `bunfs-extract-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bin`); + fs.writeFileSync(file, buf); + return file; +} + +test('discoverModuleGraph parses a well-formed synthetic StandaloneModuleGraph', () => { + const buf = buildSyntheticBinary({ + modules: [ + { name: '/$bunfs/root/cli', content: 'console.log("entry")' }, + { name: '/$bunfs/root/chunk-a.js', content: 'export const a = 1;' }, + ], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + try { + const graph = discoverModuleGraph(file); + try { + assert.equal(graph.numModules, 2); + assert.equal(graph.entryName, '/$bunfs/root/cli'); + assert.equal(graph.modules.length, 2); + const prefix = readEntryContentPrefix(graph.fd, graph.entryModule, 256).toString('utf8'); + assert.equal(prefix, 'console.log("entry")'); + } finally { + fs.closeSync(graph.fd); + } + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test('discoverModuleGraph rejects a binary with a corrupted trailer', () => { + const buf = buildSyntheticBinary({ + modules: [{ name: '/$bunfs/root/cli', content: 'x' }], + entryPointId: 0, + corruptTrailer: true, + }); + const file = writeTempBinary(buf); + try { + assert.throws(() => discoverModuleGraph(file), /trailer not found/); + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test('discoverModuleGraph rejects entry_point_id out of range', () => { + const buf = buildSyntheticBinary({ + modules: [{ name: '/$bunfs/root/cli', content: 'x' }], + entryPointId: 5, // 存在しないインデックス + }); + const file = writeTempBinary(buf); + try { + assert.throws(() => discoverModuleGraph(file), /entry_point_id.*out of range/); + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test('discoverModuleGraph rejects duplicate module names', () => { + const buf = buildSyntheticBinary({ + modules: [ + { name: '/$bunfs/root/cli', content: 'a' }, + { name: '/$bunfs/root/cli', content: 'b' }, + ], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + try { + assert.throws(() => discoverModuleGraph(file), /duplicate module name/); + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test('discoverModuleGraph skips NAPI loader modules from extraction list', () => { + const buf = buildSyntheticBinary({ + modules: [ + { name: '/$bunfs/root/cli', content: 'x' }, + { name: '/$bunfs/root/native.node', content: 'BINARY', loader: 10 }, + ], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + try { + const graph = discoverModuleGraph(file); + try { + assert.equal(graph.modules.length, 1); + assert.equal(graph.modules[0].name, '/$bunfs/root/cli'); + } finally { + fs.closeSync(graph.fd); + } + } finally { + fs.rmSync(file, { force: true }); + } +}); + +test('extractToProcessOwnedDir rejects path traversal via module name', () => { + const buf = buildSyntheticBinary({ + modules: [{ name: '/$bunfs/root/../../etc/passwd', content: 'evil' }], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + const ownedDir = path.join(os.tmpdir(), `bunfs-extract-owned-${process.pid}-${Date.now()}`); + try { + assert.throws(() => extractToProcessOwnedDir(file, ownedDir), /rejected unsafe module name|escapes owned dir/); + } finally { + fs.rmSync(file, { force: true }); + fs.rmSync(ownedDir, { recursive: true, force: true }); + } +}); + +test('extractToProcessOwnedDir writes module contents to the owned directory', () => { + const buf = buildSyntheticBinary({ + modules: [ + { name: '/$bunfs/root/cli', content: 'entry-content' }, + { name: '/$bunfs/root/chunk-a.js', content: 'chunk-content' }, + ], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + const ownedDir = path.join(os.tmpdir(), `bunfs-extract-owned-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + try { + const { entryRelPath } = extractToProcessOwnedDir(file, ownedDir); + assert.equal(entryRelPath, 'cli'); + assert.equal(fs.readFileSync(path.join(ownedDir, 'cli'), 'utf8'), 'entry-content'); + assert.equal(fs.readFileSync(path.join(ownedDir, 'chunk-a.js'), 'utf8'), 'chunk-content'); + } finally { + fs.rmSync(file, { force: true }); + fs.rmSync(ownedDir, { recursive: true, force: true }); + } +}); + +test('cleanupStaleOwnedDirs keeps directories whose PID is still alive', () => { + const workdir = path.join(os.tmpdir(), `bunfs-cleanup-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(workdir, { recursive: true }); + const aliveDir = path.join(workdir, `esm.${process.pid}.old.marker.bare-dir`); + fs.mkdirSync(aliveDir); + const oldTime = new Date(Date.now() - 48 * 60 * 60 * 1000); + fs.utimesSync(aliveDir, oldTime, oldTime); + try { + cleanupStaleOwnedDirs(workdir, 'esm.'); + assert.ok(fs.existsSync(aliveDir), 'directory owned by a live PID must not be removed even if old'); + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('cleanupStaleOwnedDirs removes old directories whose PID is dead', () => { + const workdir = path.join(os.tmpdir(), `bunfs-cleanup-test-${process.pid}-${Date.now()}-dead`); + fs.mkdirSync(workdir, { recursive: true }); + // 実在しない可能性が極めて高い巨大なPID番号を使う + const deadDir = path.join(workdir, `esm.999999999.old.marker.bare-dir`); + fs.mkdirSync(deadDir); + const oldTime = new Date(Date.now() - 48 * 60 * 60 * 1000); + fs.utimesSync(deadDir, oldTime, oldTime); + try { + cleanupStaleOwnedDirs(workdir, 'esm.'); + assert.ok(!fs.existsSync(deadDir), 'stale directory owned by a dead PID should be removed'); + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('cleanupStaleOwnedDirs keeps recently modified directories regardless of PID', () => { + const workdir = path.join(os.tmpdir(), `bunfs-cleanup-test-${process.pid}-${Date.now()}-recent`); + fs.mkdirSync(workdir, { recursive: true }); + const recentDir = path.join(workdir, `esm.999999998.recent.marker.bare-dir`); + fs.mkdirSync(recentDir); + try { + cleanupStaleOwnedDirs(workdir, 'esm.'); + assert.ok(fs.existsSync(recentDir), 'recently created directory must not be removed regardless of PID liveness'); + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('prepareProcessOwnedDir extracts into a unique directory and returns entry path', () => { + const buf = buildSyntheticBinary({ + modules: [{ name: '/$bunfs/root/cli', content: 'hello' }], + entryPointId: 0, + }); + const file = writeTempBinary(buf); + const workdir = path.join(os.tmpdir(), `bunfs-prepare-test-${process.pid}-${Date.now()}`); + fs.mkdirSync(workdir, { recursive: true }); + try { + const { ownedDir, entryRelPath } = prepareProcessOwnedDir(file, workdir); + assert.ok(ownedDir.startsWith(workdir)); + assert.equal(entryRelPath, 'cli'); + assert.equal(fs.readFileSync(path.join(ownedDir, 'cli'), 'utf8'), 'hello'); + } finally { + fs.rmSync(file, { force: true }); + fs.rmSync(workdir, { recursive: true, force: true }); + } +}); diff --git a/packages/claude-code/lib/bunfs-vm-guard.mjs b/packages/claude-code/lib/bunfs-vm-guard.mjs new file mode 100644 index 0000000..399e2db --- /dev/null +++ b/packages/claude-code/lib/bunfs-vm-guard.mjs @@ -0,0 +1,116 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const realVm = require('node:vm'); + +function injectBunIntoContext(context) { + if (!context || typeof context !== 'object') return context; + try { + if (!Object.prototype.hasOwnProperty.call(context, '__claudeYaml')) { + Object.defineProperty(context, '__claudeYaml', { + value: globalThis.__claudeYaml, + configurable: true, + writable: true, + }); + } + if (!Object.prototype.hasOwnProperty.call(context, '__claudeBunShim')) { + Object.defineProperty(context, '__claudeBunShim', { + value: globalThis.__claudeBunShim, + configurable: true, + writable: true, + }); + } + if (!Object.prototype.hasOwnProperty.call(context, '__claudeBun')) { + Object.defineProperty(context, '__claudeBun', { + value: globalThis.__claudeBunShim, + configurable: true, + writable: true, + }); + } + if (Object.prototype.hasOwnProperty.call(context, 'Bun')) { + if (context.Bun && typeof context.Bun === 'object' && context.Bun !== globalThis.Bun) { + try { + context.Bun = globalThis.Bun; + } catch { + Object.defineProperty(context, 'Bun', { + value: globalThis.Bun, + configurable: true, + writable: true, + }); + } + } + if (!context.Bun || typeof context.Bun !== 'object') { + Object.defineProperty(context, 'Bun', { + value: globalThis.Bun, + configurable: true, + writable: true, + }); + } + } else { + Object.defineProperty(context, 'Bun', { + value: globalThis.Bun, + configurable: true, + writable: true, + }); + } + if (context.Bun && globalThis.__claudeYaml) { + context.Bun.YAML = globalThis.__claudeYaml; + } + } catch {} + return context; +} + +if (!realVm.__claudeBunShimPatched) { + const originalCreateContext = realVm.createContext.bind(realVm); + const originalRunInNewContext = realVm.runInNewContext.bind(realVm); + const originalRunInContext = realVm.runInContext.bind(realVm); + const originalRunInThisContext = realVm.runInThisContext && realVm.runInThisContext.bind(realVm); + const scriptProto = realVm.Script && realVm.Script.prototype; + + realVm.createContext = (contextObject, ...rest) => + originalCreateContext(injectBunIntoContext(contextObject), ...rest); + realVm.runInNewContext = (code, contextObject, ...rest) => + originalRunInNewContext(code, injectBunIntoContext(contextObject), ...rest); + realVm.runInContext = (code, contextObject, ...rest) => + originalRunInContext(code, injectBunIntoContext(contextObject), ...rest); + if (originalRunInThisContext) { + realVm.runInThisContext = (code, ...rest) => originalRunInThisContext(code, ...rest); + } + + if (scriptProto && !scriptProto.__claudeBunShimPatched) { + const originalScriptRunInContext = scriptProto.runInContext; + const originalScriptRunInNewContext = scriptProto.runInNewContext; + const originalScriptRunInThisContext = scriptProto.runInThisContext; + + scriptProto.runInContext = function (contextObject, ...rest) { + return originalScriptRunInContext.call(this, injectBunIntoContext(contextObject), ...rest); + }; + scriptProto.runInNewContext = function (contextObject, ...rest) { + return originalScriptRunInNewContext.call(this, injectBunIntoContext(contextObject), ...rest); + }; + if (originalScriptRunInThisContext) { + scriptProto.runInThisContext = function (...rest) { + return originalScriptRunInThisContext.call(this, ...rest); + }; + } + + Object.defineProperty(scriptProto, '__claudeBunShimPatched', { value: true }); + } + + Object.defineProperty(realVm, '__claudeBunShimPatched', { value: true }); +} + +export const createContext = realVm.createContext; +export const isContext = realVm.isContext; +export const runInContext = realVm.runInContext; +export const runInNewContext = realVm.runInNewContext; +export const runInThisContext = realVm.runInThisContext; +export const createScript = realVm.createScript; +export const compileFunction = realVm.compileFunction; +export const measureMemory = realVm.measureMemory; +export const Script = realVm.Script; +export const SourceTextModule = realVm.SourceTextModule; +export const SyntheticModule = realVm.SyntheticModule; +export const constants = realVm.constants; + +export default realVm; diff --git a/packages/claude-code/lib/bunfs-vm-guard.test.js b/packages/claude-code/lib/bunfs-vm-guard.test.js new file mode 100644 index 0000000..8d9b490 --- /dev/null +++ b/packages/claude-code/lib/bunfs-vm-guard.test.js @@ -0,0 +1,142 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +test('bunfs-vm-guard exports all vm module methods', async () => { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + assert.equal(typeof vmGuard.createContext, 'function'); + assert.equal(typeof vmGuard.isContext, 'function'); + assert.equal(typeof vmGuard.runInContext, 'function'); + assert.equal(typeof vmGuard.runInNewContext, 'function'); + assert.equal(typeof vmGuard.runInThisContext, 'function'); + assert.equal(typeof vmGuard.createScript, 'function'); + assert.equal(typeof vmGuard.compileFunction, 'function'); + assert.equal(typeof vmGuard.measureMemory, 'function'); + assert.equal(typeof vmGuard.Script, 'function'); + // SourceTextModule and SyntheticModule only exist with --experimental-vm-modules flag + assert.ok('SourceTextModule' in vmGuard); + assert.ok('SyntheticModule' in vmGuard); + assert.ok(vmGuard.constants); +}); + +test('bunfs-vm-guard provides default export with vm module API', async () => { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + const defaultExport = vmGuard.default; + + assert.ok(defaultExport); + assert.equal(typeof defaultExport, 'object'); + assert.equal(typeof defaultExport.createContext, 'function'); + assert.equal(typeof defaultExport.isContext, 'function'); + assert.equal(typeof defaultExport.runInContext, 'function'); + assert.equal(typeof defaultExport.runInNewContext, 'function'); + assert.equal(typeof defaultExport.runInThisContext, 'function'); + assert.equal(typeof defaultExport.createScript, 'function'); + assert.equal(typeof defaultExport.compileFunction, 'function'); + assert.equal(typeof defaultExport.measureMemory, 'function'); + assert.equal(typeof defaultExport.Script, 'function'); + // SourceTextModule and SyntheticModule may not exist in default export without --experimental-vm-modules + // They are exported as named exports if they exist + assert.ok(defaultExport.constants); +}); + +test('bunfs-vm-guard injects Bun into context in createContext', async () => { + globalThis.Bun = { __dummy: true }; + try { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + const context = vmGuard.createContext({}); + // Check that Bun property exists in context + assert.ok(Object.prototype.hasOwnProperty.call(context, 'Bun')); + + // Verify Bun is defined when we run code in context + const result = vmGuard.runInContext('typeof Bun', context); + assert.equal(result, 'object'); + } finally { + delete globalThis.Bun; + } +}); + +test('bunfs-vm-guard injects Bun into context in runInNewContext', async () => { + globalThis.Bun = { __dummy: true }; + try { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + const code = 'typeof Bun'; + const result = vmGuard.runInNewContext(code, {}); + assert.equal(result, 'object'); + } finally { + delete globalThis.Bun; + } +}); + +test('bunfs-vm-guard injects __claudeYaml into context', async () => { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + const context = vmGuard.createContext({}); + // Check that __claudeYaml property exists + assert.ok(Object.prototype.hasOwnProperty.call(context, '__claudeYaml')); +}); + +test('bunfs-vm-guard injects __claudeBun and __claudeBunShim into context', async () => { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + const context = vmGuard.createContext({}); + // Check that shim properties exist + assert.ok(Object.prototype.hasOwnProperty.call(context, '__claudeBun')); + assert.ok(Object.prototype.hasOwnProperty.call(context, '__claudeBunShim')); +}); + +test('bunfs-vm-guard Script class works with context injection', async () => { + globalThis.Bun = { __dummy: true }; + try { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + const code = 'typeof Bun'; + const script = new vmGuard.Script(code); + const context = vmGuard.createContext({}); + const result = script.runInContext(context); + assert.equal(result, 'object'); + } finally { + delete globalThis.Bun; + } +}); + +test('bunfs-vm-guard handles context with existing Bun property', async () => { + globalThis.Bun = { __dummy: true }; + try { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + // Create context with pre-existing Bun property + const context = vmGuard.createContext({ Bun: { custom: true } }); + // The guard should have replaced/overwritten it with globalThis.Bun + const result = vmGuard.runInContext('typeof Bun', context); + assert.equal(result, 'object'); + } finally { + delete globalThis.Bun; + } +}); + +test('bunfs-vm-guard does not break normal vm functionality', async () => { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + // Test that normal code execution still works + const code = '2 + 2'; + const result = vmGuard.runInNewContext(code); + assert.equal(result, 4); +}); + +test('bunfs-vm-guard Script.runInNewContext works with injections', async () => { + globalThis.Bun = { __dummy: true }; + try { + const vmGuard = await import('./bunfs-vm-guard.mjs'); + + const code = 'typeof Bun'; + const script = new vmGuard.Script(code); + const result = script.runInNewContext({}); + assert.equal(result, 'object'); + } finally { + delete globalThis.Bun; + } +}); diff --git a/packages/claude-code/lib/bunfs-ws-stub.mjs b/packages/claude-code/lib/bunfs-ws-stub.mjs new file mode 100644 index 0000000..177f36c --- /dev/null +++ b/packages/claude-code/lib/bunfs-ws-stub.mjs @@ -0,0 +1,11 @@ +class WS { + on() {} + once() {} + addEventListener() {} + close() {} + send() {} + ping() {} +} + +export default WS; +export { WS as WebSocket }; diff --git a/packages/claude-code/lib/bunfs-yaml-shim.mjs b/packages/claude-code/lib/bunfs-yaml-shim.mjs new file mode 100644 index 0000000..d7e9f38 --- /dev/null +++ b/packages/claude-code/lib/bunfs-yaml-shim.mjs @@ -0,0 +1,91 @@ +function parseScalar(value) { + const text = String(value ?? '').trim(); + if (text === '') return ''; + if (text === 'true') return true; + if (text === 'false') return false; + if (text === 'null' || text === '~') return null; + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(text)) return Number(text); + if ( + (text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'")) + ) { + return text.slice(1, -1); + } + return text; +} + +function parseInlineArray(value) { + const inner = String(value ?? '').trim().slice(1, -1).trim(); + if (inner === '') return []; + const items = []; + let current = ''; + let quote = null; + + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]; + if (quote) { + if (ch === quote && inner[i - 1] !== '\\') quote = null; + current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + if (ch === ',') { + items.push(parseScalar(current)); + current = ''; + continue; + } + current += ch; + } + + if (current !== '') items.push(parseScalar(current)); + return items; +} + +export function yamlParse(text) { + const source = String(text ?? ''); + const result = {}; + for (const rawLine of source.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const idx = line.indexOf(':'); + if (idx < 0) continue; + const key = line.slice(0, idx).trim(); + const rawValue = line.slice(idx + 1).trim(); + if (!key) continue; + result[key] = rawValue.startsWith('[') && rawValue.endsWith(']') + ? parseInlineArray(rawValue) + : parseScalar(rawValue); + } + return result; +} + +export function yamlStringify(value) { + if (!value || typeof value !== 'object') return String(value ?? ''); + const lines = []; + for (const [key, raw] of Object.entries(value)) { + if (Array.isArray(raw)) { + lines.push(`${key}: [${raw.map(item => JSON.stringify(String(item))).join(', ')}]`); + } else if (raw === null) { + lines.push(`${key}: null`); + } else if (typeof raw === 'string') { + lines.push(`${key}: ${JSON.stringify(raw)}`); + } else { + lines.push(`${key}: ${String(raw)}`); + } + } + return lines.join('\n'); +} + +export function createYamlShim() { + const yaml = { + parse: yamlParse, + stringify: yamlStringify, + }; + yaml.YAML = yaml; + yaml.default = yaml; + return yaml; +} diff --git a/packages/claude-code/lib/prepare-native.js b/packages/claude-code/lib/prepare-native.js index efbcf74..82028fe 100755 --- a/packages/claude-code/lib/prepare-native.js +++ b/packages/claude-code/lib/prepare-native.js @@ -128,6 +128,14 @@ function verifyTarball(file, audited) { } 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); @@ -144,3 +152,25 @@ function validateOffsets(file, audited) { 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 6f3fe05..08941fb 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -3,8 +3,14 @@ set -eu SOURCE_BIN="${SOURCE_BIN:?SOURCE_BIN is required}" WORKDIR="${WORKDIR:-${HOME}/.claude-termux-native-package/launcher-workdir}" -ENTRY_JS_OFFSET="${ENTRY_JS_OFFSET:?ENTRY_JS_OFFSET is required}" -ENTRY_END_OFFSET="${ENTRY_END_OFFSET:?ENTRY_END_OFFSET is required}" +ENTRY_FORMAT="${ENTRY_FORMAT:-legacy-cjs}" +if [ "${ENTRY_FORMAT}" = "esm-chunked" ]; then + ENTRY_JS_OFFSET="${ENTRY_JS_OFFSET:-0}" + ENTRY_END_OFFSET="${ENTRY_END_OFFSET:-0}" +else + ENTRY_JS_OFFSET="${ENTRY_JS_OFFSET:?ENTRY_JS_OFFSET is required}" + ENTRY_END_OFFSET="${ENTRY_END_OFFSET:?ENTRY_END_OFFSET is required}" +fi CURRENT_CLAUDE_VERSION="${CURRENT_CLAUDE_VERSION:?CURRENT_CLAUDE_VERSION is required}" CLAUDE_TERMUX_PACKAGE_DIR="${CLAUDE_TERMUX_PACKAGE_DIR:?CLAUDE_TERMUX_PACKAGE_DIR is required}" TERMUX_TMPDIR="${TMPDIR:-/data/data/com.termux/files/usr/tmp}" @@ -45,6 +51,7 @@ export SSL_CERT_FILE export DISABLE_AUTOUPDATER export SOURCE_BIN export WORKDIR +export ENTRY_FORMAT export ENTRY_JS_OFFSET export ENTRY_END_OFFSET export CURRENT_CLAUDE_VERSION @@ -656,7 +663,60 @@ function rewriteNativeChunkSource(source) { return patched; } -async function main() { +async function esmChunkedMain() { + const { prepareProcessOwnedDir } = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib', 'bunfs-extract.js')); + const { register } = require('node:module'); + const { pathToFileURL } = require('node:url'); + + const { ownedDir, entryRelPath } = prepareProcessOwnedDir(sourceBin, workdir); + const libDir = path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib'); + + globalThis.__claudeYaml = createYamlShim(); + globalThis.Bun = { + version: '1.1.8', + stringWidth, + wrapAnsi, + stripANSI, + hash: stableHash, + which: (cmd) => { + try { + return require('child_process').execFileSync('which', [String(cmd)], { encoding: 'utf8' }).trim() || null; + } catch { return null; } + }, + gc: () => {}, + YAML: globalThis.__claudeYaml, + }; + Object.defineProperty(process.versions, 'bun', { value: '1.1.8', configurable: true }); + 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'), + }, + }); + + const entryUrl = pathToFileURL(path.join(ownedDir, entryRelPath)).href; + + // 2.1.245実チャンクのエントリは、内部のmain相当処理をトップレベルでawaitせず + // fire-and-forgetで起動する(Bunランタイム前提の実装)。そのためawait import()は + // 内部の非同期処理が完了する前に解決してしまい、legacy-cjs経路のような + // process.exitパッチ+ここでの強制exit呼び出しを行うと、まだ実行中の内部処理を + // 強制終了させ出力が失われる(実機で確認済み)。process.exit/killは一切パッチせず、 + // 実際のCLIコードが自ら呼ぶprocess.exit()に任せてNodeの自然なイベントループ終了を + // 待つ(この関数はawait import()完了後、何もせずreturnするだけでよい)。 + // 同じ理由で、ここでglobalThis.Bun/__claudeYamlを削除するcleanupも行わない + // (fire-and-forgetの内部処理がimport()解決後も継続してBunを参照するため、 + // 早期に消すと実機で"Bun is not defined"を引き起こす。プロセス終了まで残す)。 + await import(entryUrl); +} + +async function legacyCjsMain() { let extractedFile; extractedFile = ensureEntryFile(); const code = fs.readFileSync(extractedFile, 'utf8'); @@ -1000,6 +1060,10 @@ async function main() { } } +function main() { + return process.env.ENTRY_FORMAT === 'esm-chunked' ? esmChunkedMain() : legacyCjsMain(); +} + main().catch(error => { if (error && error.code === 'CLAUDE_TERMUX_OFFICIAL_UPDATE_BLOCKED') { console.error(BLOCK_MESSAGE); @@ -1613,7 +1677,60 @@ function rewriteNativeChunkSource(source) { return patched; } -async function main() { +async function esmChunkedMain() { + const { prepareProcessOwnedDir } = require(path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib', 'bunfs-extract.js')); + const { register } = require('node:module'); + const { pathToFileURL } = require('node:url'); + + const { ownedDir, entryRelPath } = prepareProcessOwnedDir(sourceBin, workdir); + const libDir = path.join(process.env.CLAUDE_TERMUX_PACKAGE_DIR, 'lib'); + + globalThis.__claudeYaml = createYamlShim(); + globalThis.Bun = { + version: '1.1.8', + stringWidth, + wrapAnsi, + stripANSI, + hash: stableHash, + which: (cmd) => { + try { + return require('child_process').execFileSync('which', [String(cmd)], { encoding: 'utf8' }).trim() || null; + } catch { return null; } + }, + gc: () => {}, + YAML: globalThis.__claudeYaml, + }; + Object.defineProperty(process.versions, 'bun', { value: '1.1.8', configurable: true }); + 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'), + }, + }); + + const entryUrl = pathToFileURL(path.join(ownedDir, entryRelPath)).href; + + // 2.1.245実チャンクのエントリは、内部のmain相当処理をトップレベルでawaitせず + // fire-and-forgetで起動する(Bunランタイム前提の実装)。そのためawait import()は + // 内部の非同期処理が完了する前に解決してしまい、legacy-cjs経路のような + // process.exitパッチ+ここでの強制exit呼び出しを行うと、まだ実行中の内部処理を + // 強制終了させ出力が失われる(実機で確認済み)。process.exit/killは一切パッチせず、 + // 実際のCLIコードが自ら呼ぶprocess.exit()に任せてNodeの自然なイベントループ終了を + // 待つ(この関数はawait import()完了後、何もせずreturnするだけでよい)。 + // 同じ理由で、ここでglobalThis.Bun/__claudeYamlを削除するcleanupも行わない + // (fire-and-forgetの内部処理がimport()解決後も継続してBunを参照するため、 + // 早期に消すと実機で"Bun is not defined"を引き起こす。プロセス終了まで残す)。 + await import(entryUrl); +} + +async function legacyCjsMain() { let extractedFile; extractedFile = ensureEntryFile(); const code = fs.readFileSync(extractedFile, 'utf8'); @@ -1961,8 +2078,19 @@ async function main() { } } +function main() { + return process.env.ENTRY_FORMAT === 'esm-chunked' ? esmChunkedMain() : legacyCjsMain(); +} + main() .then(() => { + // esm-chunked経路は内部のfire-and-forget非同期処理が実際のprocess.exit()を + // 自ら呼ぶまでNodeのイベントループを生かしておく必要があるため、TUIモードと + // 同様にここでは強制exitしない(実機で確認済み: 強制exitすると--helpの出力等が + // 完了前に打ち切られる)。 + if (process.env.ENTRY_FORMAT === 'esm-chunked') { + return; + } if (process.env.CLAUDE_TERMUX_TUI === '1' && process.exitCode === undefined) { return; } 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 c56a982..7f375fe 100644 --- a/packages/claude-code/lib/termux-run-claude-native.test.js +++ b/packages/claude-code/lib/termux-run-claude-native.test.js @@ -81,7 +81,7 @@ function loadHelperApi() { const rewriteSource = extractFunction( helperBlock, 'function rewriteNativeChunkSource(source) {', - '\n\nasync function main() {', + '\n\nasync function esmChunkedMain() {', ); const context = vm.createContext({ module: { exports: {} }, exports: {}, fs, path, process }); @@ -113,12 +113,12 @@ test('helper and bootstrap rewrite helpers stay identical', () => { const helperRewrite = extractFunction( helperBlock, 'function rewriteNativeChunkSource(source) {', - '\n\nasync function main() {', + '\n\nasync function esmChunkedMain() {', ); const bootstrapRewrite = extractFunction( bootstrapBlock, 'function rewriteNativeChunkSource(source) {', - '\n\nasync function main() {', + '\n\nasync function esmChunkedMain() {', ); const helperWrapAnsi = extractFunction( helperBlock, diff --git a/packages/claude-code/package.json b/packages/claude-code/package.json index 2dda5f7..2125763 100644 --- a/packages/claude-code/package.json +++ b/packages/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@bash0816/claude-code", - "version": "2.1.241", + "version": "2.1.245", "description": "Unofficial Termux-native Claude Code wrapper with audited native replay", "license": "GPL-3.0-only", "bin": { @@ -15,7 +15,7 @@ "LICENSE" ], "engines": { - "node": ">=18" + "node": ">=20.6.0" }, "keywords": [ "claude-code", diff --git a/scripts/add-candidate-metadata.js b/scripts/add-candidate-metadata.js index 78c7126..6c2bc64 100644 --- a/scripts/add-candidate-metadata.js +++ b/scripts/add-candidate-metadata.js @@ -39,16 +39,32 @@ function assertSameVersionKeys() { function main() { const offsets = loadJson(path.resolve(offsetFile)); + const entryFormat = offsets.entry_format === 'esm-chunked' ? 'esm-chunked' : 'legacy-cjs'; + const versionEntry = { wrapper_spec: `@anthropic-ai/claude-code@${version}`, native_spec: `@anthropic-ai/claude-code-linux-arm64@${version}`, - entry_js_offset: offsets.entry_js_offset, - entry_end_offset: offsets.entry_end_offset, + entry_format: entryFormat, tarball_integrity: offsets.tarball_integrity, tarball_sha256: offsets.tarball_sha256, status: 'offset_discovered', }; + if (entryFormat === 'esm-chunked') { + if (!(offsets.num_modules > 0)) { + throw new Error('esm-chunked offsets missing num_modules'); + } + if (!(offsets.byte_count > 0)) { + throw new Error('esm-chunked offsets missing byte_count'); + } + } 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'); + } + versionEntry.entry_js_offset = offsets.entry_js_offset; + versionEntry.entry_end_offset = offsets.entry_end_offset; + } + const updatedFiles = []; for (const file of configFiles) { diff --git a/scripts/termux-prepare-claude-native-version.js b/scripts/termux-prepare-claude-native-version.js index 25b6f60..dd63cd9 100755 --- a/scripts/termux-prepare-claude-native-version.js +++ b/scripts/termux-prepare-claude-native-version.js @@ -82,37 +82,85 @@ function sha256(file) { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } -function discoverOffsets(binary) { - const buf = fs.readFileSync(binary); +function discoverLegacyCjsOffsets(buf) { 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'); const endOffset = buf.indexOf(endMarker); - if (endOffset < 0) throw new Error('failed to find embedded JS end marker'); + if (endOffset < 0) return null; let startOffset = -1; let next = -1; while ((next = buf.indexOf(startMarker, next + 1)) !== -1) { if (next < endOffset) startOffset = next; } - if (startOffset < 0) throw new Error('failed to find embedded JS start marker'); + if (startOffset < 0) return null; const entry = buf.subarray(startOffset, endOffset).toString('utf8').replace(/[\0\s]+$/g, ''); - if (!entry.startsWith('function(exports, require, module, __filename, __dirname) {')) { - throw new Error('embedded JS start validation failed'); - } - if (!entry.endsWith('})')) { - throw new Error('embedded JS end validation failed'); - } + if (!entry.startsWith('function(exports, require, module, __filename, __dirname) {')) return null; + if (!entry.endsWith('})')) return null; return { - binary, - binary_size: buf.length, + entry_format: 'legacy-cjs', entry_js_offset: startOffset, entry_end_offset: endOffset, entry_size: endOffset - startOffset, }; } +function discoverEsmChunkedOffsets(binary) { + // StandaloneModuleGraphコンテナ自体はlegacy-cjs(単一CJSラッパー)・esm-chunked + // (1387個のESMチャンク)のどちらのバージョンにも存在する(実測確認: 2.1.241でも + // 11モジュールのグラフが見つかる)。コンテナの有無では形式を判別できないため、 + // エントリモジュール("cli")の実コンテンツ先頭を見て、CJSラッパー関数 + // (`function(exports, require, module, __filename, __dirname) {`)で始まって + // いなければesm-chunkedと判定する。 + const { discoverModuleGraph, readEntryContentPrefix } = require(path.join(__dirname, '..', 'packages', 'claude-code', 'lib', 'bunfs-extract.js')); + const graph = discoverModuleGraph(binary); + try { + const prefix = readEntryContentPrefix(graph.fd, graph.entryModule, 256).toString('utf8'); + const cjsWrapperPrefix = 'function(exports, require, module, __filename, __dirname) {'; + // コメント行を除いた実コード部分がCJSラッパーで始まっていればlegacy-cjs形式であり、 + // esm-chunkedとしては検出しない(discoverOffsets側でlegacy検出にフォールバックさせる)。 + // 実バイナリでは`(function(exports, ...) {`のように先頭に丸括弧が付くため、 + // 括弧を許容してチェックする。 + const codeStart = prefix.replace(/^(\s*\/\/[^\n]*\n)+/, '').replace(/^\(/, ''); + if (codeStart.startsWith(cjsWrapperPrefix)) { + throw new Error('entry module is legacy-cjs wrapped, not esm-chunked'); + } + return { + entry_format: 'esm-chunked', + num_modules: graph.numModules, + byte_count: graph.byteCount, + }; + } finally { + fs.closeSync(graph.fd); + } +} + +function discoverOffsets(binary) { + const binarySize = fs.statSync(binary).size; + + // esm-chunked検出はトレイラー起点の範囲readSyncのみで完結し、ファイル全体を + // メモリへ読み込まない(389MB超のバイナリでOOMを避けるため、こちらを先に試す)。 + let esmChunkedError; + try { + const esmChunked = discoverEsmChunkedOffsets(binary); + return { binary, binary_size: binarySize, ...esmChunked }; + } catch (error) { + esmChunkedError = error; + } + + // legacy-cjs検出はファイル全体のバイト列走査が必要(マーカー文字列が任意の位置に + // ありうるため)。esm-chunked検出が失敗した場合のみ、ここで初めて全体を読み込む。 + const buf = fs.readFileSync(binary); + const legacy = discoverLegacyCjsOffsets(buf); + if (legacy) { + return { binary, binary_size: binarySize, ...legacy }; + } + + throw new Error(`failed to find embedded JS start marker (esm-chunked detection failed: ${esmChunkedError.message}; legacy-cjs marker also not found)`); +} + function main() { const version = resolveVersion(requested); const workdir = process.env.WORKDIR || path.join(os.tmpdir(), `claude-${version}-native-poc`); @@ -150,8 +198,14 @@ function main() { } else { console.log(`Prepared Claude Code native candidate: ${version}`); console.log(`source_bin: ${sourceBin}`); - console.log(`entry_js_offset: ${result.entry_js_offset}`); - console.log(`entry_end_offset: ${result.entry_end_offset}`); + console.log(`entry_format: ${result.entry_format}`); + if (result.entry_format === 'esm-chunked') { + console.log(`num_modules: ${result.num_modules}`); + console.log(`byte_count: ${result.byte_count}`); + } else { + console.log(`entry_js_offset: ${result.entry_js_offset}`); + console.log(`entry_end_offset: ${result.entry_end_offset}`); + } console.log(`tarball_integrity: ${result.tarball_integrity}`); console.log(`tarball_sha256: ${result.tarball_sha256}`); } diff --git a/scripts/verify-claude-native-e2e.sh b/scripts/verify-claude-native-e2e.sh new file mode 100755 index 0000000..c5f1a2d --- /dev/null +++ b/scripts/verify-claude-native-e2e.sh @@ -0,0 +1,437 @@ +#!/bin/bash + +# E2E Evidence Collection Script for claude-code +# 実行開始時刻、環境、キャッシュ確認、4つのコマンド実行とログ収集、tarball検証を実施 + +set -e + +# 第1引数でVERSIONを必須化 +VERSION="${1:?VERSION argument is required (e.g., 2.1.245)}" + +# タイムスタンプの生成(実行ごとにユニークなディレクトリを作成) +TIMESTAMP=$(date -u '+%Y%m%d-%H%M%S') +RUN_DIR=".verify-${VERSION}/clean-cache-e2e/run-${TIMESTAMP}" +CACHE_DIR="${RUN_DIR}/cache" +LOGS_DIR="${RUN_DIR}/logs" + +# 実行開始時刻 +START_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + +echo "Evidence collection started: $START_TIME" +echo "Run directory: $RUN_DIR" +echo "Target version: $VERSION" + +# ディレクトリを作成 +mkdir -p "$CACHE_DIR" "$LOGS_DIR" + +# configから該当バージョンの情報を読み込む +CONFIG_FILE="packages/claude-code/config/claude-native-audited-versions.json" +if [ ! -f "$CONFIG_FILE" ]; then + echo "ERROR: Config file not found: $CONFIG_FILE" >&2 + exit 1 +fi + +# Node.jsでJSONからバージョン情報を抽出 +VERSION_INFO=$(node - <<'NODE_SCRIPT' "$VERSION" "$CONFIG_FILE" 2>&1 +const fs = require('fs'); +const path = require('path'); + +const version = process.argv[2]; +const configFile = process.argv[3]; + +try { + const configContent = fs.readFileSync(configFile, 'utf8'); + const config = JSON.parse(configContent); + + if (!config.versions || !config.versions[version]) { + console.error(`ERROR: Version ${version} not found in config`); + process.exit(1); + } + + const versionData = config.versions[version]; + if (!versionData.native_spec) { + console.error(`ERROR: native_spec not found for version ${version}`); + process.exit(1); + } + + if (!versionData.tarball_sha256) { + console.error(`ERROR: tarball_sha256 not found for version ${version}`); + process.exit(1); + } + + if (!versionData.tarball_integrity) { + console.error(`ERROR: tarball_integrity not found for version ${version}`); + process.exit(1); + } + + console.log(JSON.stringify({ + nativeSpec: versionData.native_spec, + tarballSha256: versionData.tarball_sha256, + tarballIntegrity: versionData.tarball_integrity, + })); +} catch (err) { + console.error(`ERROR: Failed to read config: ${err.message}`); + process.exit(1); +} +NODE_SCRIPT +) + +if [ $? -ne 0 ]; then + echo "Failed to extract version info from config" >&2 + exit 1 +fi + +# JSONを解析してbashスクリプト内で使う +NATIVE_SPEC=$(echo "$VERSION_INFO" | node -e "const data = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(data.nativeSpec);") +TARBALL_SHA256=$(echo "$VERSION_INFO" | node -e "const data = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(data.tarballSha256);") +TARBALL_INTEGRITY=$(echo "$VERSION_INFO" | node -e "const data = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(data.tarballIntegrity);") + +echo "Extracted from config:" +echo " Native spec: $NATIVE_SPEC" +echo " Tarball SHA256: $TARBALL_SHA256" +echo "" + +# グローバルな失敗カウンタ +OVERALL_FAILURES=0 + +# キャッシュディレクトリが空であることを確認 +EMPTY_CHECK_OUTPUT=$LOGS_DIR/empty-cache-check.log +{ + echo "Cache directory check at $START_TIME" + echo "Directory: $CACHE_DIR" + if find "$CACHE_DIR" -mindepth 1 -type f -o -type d 2>/dev/null | wc -l | grep -q '^0$'; then + echo "Status: PASS - Cache directory is empty" + else + echo "Status: FAIL - Cache directory contains files/directories:" + ls -laR "$CACHE_DIR" || true + fi +} > "$EMPTY_CHECK_OUTPUT" 2>&1 + +# キャッシュが空でなかったかチェック +CACHE_RESULT=$(tail -1 "$EMPTY_CHECK_OUTPUT" | grep -o "^Status: .*") +if [ "$CACHE_RESULT" != "Status: PASS - Cache directory is empty" ]; then + OVERALL_FAILURES=$((OVERALL_FAILURES + 1)) +fi + +# 環境変数を記録する関数(機密情報をマスク) +# 重要: 実際にbin/claudeへ渡す`env -i HOME=... PATH=... TMPDIR=... CLAUDE_TERMUX_PACKAGE_CACHE=...` +# というクリーン環境そのものを記録する。呼び出し元シェル(このスクリプト自身)の +# ambient環境を記録すると、このセッションで以前使われた無関係な残留変数 +# (例: 別バージョンのテストで残ったCURRENT_CLAUDE_VERSION等)を誤って記録してしまう。 +record_env() { + local env_file="$1" + local cache_dir="$2" + env -i HOME="$HOME" PATH="$PATH" TMPDIR="/data/data/com.termux/files/usr/tmp" CLAUDE_TERMUX_PACKAGE_CACHE="$cache_dir" env \ + | while IFS='=' read -r name value; do + # 機密情報の文字列を含む変数名をマスク + if echo "$name" | grep -qiE 'ANTHROPIC|TOKEN|KEY|SECRET|PASSWORD|AUTH'; then + echo "$name=***REDACTED***" + else + echo "$name=$value" + fi + done | sort > "$env_file" +} + +# 4つのコマンドを実行して証跡を収集 +run_test_command() { + local cmd_num="$1" + local cmd_args="$2" + local cmd_name="$3" + local expected_exit_code="${4:-0}" + + local cmd_start=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + local stdout_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-stdout.log" + local stderr_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-stderr.log" + local env_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-env.log" + local meta_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-meta.log" + + echo "Running command $cmd_num: $cmd_name" + + # コマンド全体とメタ情報を記録 + { + echo "Command: sh packages/claude-code/bin/claude $cmd_args" + echo "Started: $cmd_start" + } > "$meta_file" + + # 環境変数を記録(実際にbin/claudeへ渡すクリーン環境そのもの) + record_env "$env_file" "$CACHE_DIR" + + # コマンドを実行、stdout/stderr を分離して記録 + local exit_code=0 + env -i HOME="$HOME" PATH="$PATH" TMPDIR="/data/data/com.termux/files/usr/tmp" CLAUDE_TERMUX_PACKAGE_CACHE="$CACHE_DIR" \ + sh packages/claude-code/bin/claude $cmd_args \ + > "$stdout_file" 2> "$stderr_file" || exit_code=$? + + local cmd_end=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + + # メタ情報に終了時刻とexit codeを追記 + { + echo "Ended: $cmd_end" + echo "Exit code: $exit_code" + echo "Expected exit code: $expected_exit_code" + } >> "$meta_file" + + # stdout/stderr の行数を記録 + local stdout_lines=$(wc -l < "$stdout_file" 2>/dev/null || echo 0) + local stderr_lines=$(wc -l < "$stderr_file" 2>/dev/null || echo 0) + + echo " Exit code: $exit_code (expected: $expected_exit_code)" + echo " Stdout lines: $stdout_lines" + echo " Stderr lines: $stderr_lines" + + # exit codeの検証 + if [ "$exit_code" -ne "$expected_exit_code" ]; then + echo " ERROR: Exit code mismatch! Expected $expected_exit_code but got $exit_code" + OVERALL_FAILURES=$((OVERALL_FAILURES + 1)) + fi + + return 0 # コマンドの失敗時もスクリプトを継続させる +} + +# 4つのコマンドを実行 +run_test_command 1 "--version" "version" 0 +run_test_command 2 "--help" "help" 0 +run_test_command 3 "--nonexistent-flag" "nonexistent-flag" 1 +run_test_command 4 "doctor" "doctor" 0 + +# Tarball のハッシュ値を検証(Node.js crypto を使用) +HASH_CHECK_FILE="$LOGS_DIR/tarball-hash-check.log" +TARBALL_DOWNLOAD_DIR="${RUN_DIR}/tarball-download" +mkdir -p "$TARBALL_DOWNLOAD_DIR" + +# Node.js スクリプトでハッシュ検証を実行 +export TARBALL_DOWNLOAD_DIR TARGET_VERSION="$VERSION" NATIVE_SPEC="$NATIVE_SPEC" EXPECTED_SHA256="$TARBALL_SHA256" EXPECTED_INTEGRITY="$TARBALL_INTEGRITY" +node - <<'NODE_SCRIPT' > "$HASH_CHECK_FILE" 2>&1 +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const { exec } = require('child_process'); +const { promisify } = require('util'); +const execPromise = promisify(exec); +const path = require('path'); + +const tarballDir = process.env.TARBALL_DOWNLOAD_DIR; +const version = process.env.TARGET_VERSION; +const nativeSpec = process.env.NATIVE_SPEC; +const expectedSha256 = process.env.EXPECTED_SHA256; +const expectedIntegrity = process.env.EXPECTED_INTEGRITY; + +(async () => { + try { + console.log(`Tarball hash verification for ${version}`); + console.log('=========================================='); + console.log(''); + console.log(`Expected tarball_sha256 (from config): ${expectedSha256}`); + console.log(`Expected tarball_integrity (sha512): ${expectedIntegrity}`); + console.log(''); + + // npm view でtarball URLを取得 + console.log('Fetching tarball URL from npm registry...'); + let tarballUrl = ''; + try { + const { stdout } = await execPromise(`npm view ${nativeSpec} dist.tarball --json`); + tarballUrl = JSON.parse(stdout); + } catch (e) { + console.error('ERROR: Failed to fetch tarball URL from npm registry'); + console.log('Result: FAIL - Could not retrieve tarball URL'); + process.exit(1); + } + + if (!tarballUrl) { + console.error('ERROR: npm view returned empty tarball URL'); + console.log('Result: FAIL - Tarball URL is empty'); + process.exit(1); + } + + console.log('Tarball URL: ' + tarballUrl); + console.log(''); + + // tarball をダウンロード + const tarballFile = path.join(tarballDir, `native-${version}.tgz`); + console.log('Downloading tarball to: ' + tarballFile); + + const buf = await new Promise((resolve, reject) => { + https.get(tarballUrl, (res) => { + if (res.statusCode !== 200) { + reject(new Error('HTTP ' + res.statusCode)); + } + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }).on('error', reject); + }); + + fs.writeFileSync(tarballFile, buf); + console.log('Download successful'); + console.log(''); + + // ファイルサイズを確認 + const tarballSize = buf.length; + console.log('Tarball size: ' + tarballSize + ' bytes'); + + // SHA256 ハッシュを計算 + const actualSha256 = crypto.createHash('sha256').update(buf).digest('hex'); + console.log('Actual tarball_sha256: ' + actualSha256); + console.log(''); + + // SHA512 ハッシュ(base64)を計算 + const sha512Digest = crypto.createHash('sha512').update(buf).digest('base64'); + const actualIntegrity = 'sha512-' + sha512Digest; + console.log('Actual tarball_integrity: ' + actualIntegrity); + console.log(''); + + // ハッシュ値を比較 + let sha256Match = false; + let integrityMatch = false; + + if (actualSha256 === expectedSha256) { + console.log('SHA256 Verification: PASS'); + sha256Match = true; + } else { + console.log('SHA256 Verification: FAIL (mismatch)'); + } + + if (actualIntegrity === expectedIntegrity) { + console.log('SHA512 Integrity Verification: PASS'); + integrityMatch = true; + } else { + console.log('SHA512 Integrity Verification: FAIL (mismatch)'); + } + + console.log(''); + if (sha256Match && integrityMatch) { + console.log('Result: PASS - All verifications passed'); + process.exit(0); + } else { + console.log('Result: FAIL - Some verifications failed'); + process.exit(1); + } + } catch (err) { + console.error('ERROR: ' + (err.message || String(err))); + console.log('Result: FAIL - Verification error'); + process.exit(1); + } +})(); +NODE_SCRIPT + +# tarball検証のexit codeをチェック +if [ $? -ne 0 ]; then + OVERALL_FAILURES=$((OVERALL_FAILURES + 1)) +fi + +# Markdown レポートを生成 +REPORT_FILE="${RUN_DIR}/EVIDENCE-REPORT.md" +END_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + +{ + cat << EOF +# E2E Evidence Report: claude-code ${VERSION} Clean Cache Test + +## Execution Timeline + +- **Start Time**: $START_TIME +- **End Time**: $END_TIME +- **Target Version**: $VERSION + +## Cache Verification + +**Empty Cache Check**: Passed - Cache directory was created fresh for this test +- Directory: $CACHE_DIR + +EOF + + # 4つのコマンドの結果を記録 + for cmd_num in 1 2 3 4; do + case $cmd_num in + 1) cmd_args="--version"; cmd_name="version"; expected_code=0 ;; + 2) cmd_args="--help"; cmd_name="help"; expected_code=0 ;; + 3) cmd_args="--nonexistent-flag"; cmd_name="nonexistent-flag"; expected_code=1 ;; + 4) cmd_args="doctor"; cmd_name="doctor"; expected_code=0 ;; + esac + + meta_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-meta.log" + stdout_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-stdout.log" + stderr_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-stderr.log" + env_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-env.log" + + cat << EOF + +### Command $cmd_num: \`claude $cmd_args\` + +EOF + + # メタ情報を抽出 + if [ -f "$meta_file" ]; then + awk '/^(Command|Started|Ended|Exit code|Expected exit code):/{print "- " $0}' "$meta_file" + fi + + # stdout/stderr の行数 + stdout_lines=$(wc -l < "$stdout_file" 2>/dev/null || echo 0) + stderr_lines=$(wc -l < "$stderr_file" 2>/dev/null || echo 0) + + cat << EOF + +- **Stdout Lines**: $stdout_lines +- **Stderr Lines**: $([ "$stderr_lines" -eq 0 ] && echo "None (0 lines)" || echo "$stderr_lines") + +**Output Files**: +- Stdout: \`$stdout_file\` +- Stderr: \`$stderr_file\` +- Environment: \`$env_file\` + +EOF + done + + # Tarball ハッシュ検証結果 + cat << EOF + +## Tarball Hash Verification + +EOF + + if [ -f "$HASH_CHECK_FILE" ]; then + sed 's/^/- /' "$HASH_CHECK_FILE" + fi + + cat << EOF + +## Environment Variables (Machine-Redacted) + +Full environment variable list (with sensitive values redacted): + +\`\`\` +EOF + + if [ -f "$LOGS_DIR/cmd1-version-env.log" ]; then + cat "$LOGS_DIR/cmd1-version-env.log" + fi + + cat << EOF +\`\`\` + +## Summary + +All evidence files are available in the following directory: +- **Run Directory**: \`$RUN_DIR\` +- **Logs Directory**: \`$LOGS_DIR\` + +Overall Result: $([ "$OVERALL_FAILURES" -eq 0 ] && echo "PASS" || echo "FAIL ($OVERALL_FAILURES failures)") + +Generated at: $END_TIME +EOF +} > "$REPORT_FILE" + +# レポートをコンソールに表示 +echo "" +echo "================================================" +echo "Evidence collection completed!" +echo "================================================" +cat "$REPORT_FILE" + +# 最終的なexit code +if [ "$OVERALL_FAILURES" -ne 0 ]; then + exit 1 +else + exit 0 +fi From b0d21da8ef7908e542f7d1defa62ead2e30ead06 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Fri, 28 Aug 2026 05:15:19 +0900 Subject: [PATCH 2/2] fix: address shellcheck warnings in verify-claude-native-e2e.sh - SC2155: Separate local declaration and assignment for cmd_start, cmd_end, stdout_lines, stderr_lines - SC2034: Remove unused expected_code variable from case statement in report generation loop Co-Authored-By: Claude Sonnet 5 --- scripts/verify-claude-native-e2e.sh | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/verify-claude-native-e2e.sh b/scripts/verify-claude-native-e2e.sh index c5f1a2d..9405775 100755 --- a/scripts/verify-claude-native-e2e.sh +++ b/scripts/verify-claude-native-e2e.sh @@ -139,7 +139,8 @@ run_test_command() { local cmd_name="$3" local expected_exit_code="${4:-0}" - local cmd_start=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + local cmd_start + cmd_start=$(date -u '+%Y-%m-%dT%H:%M:%SZ') local stdout_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-stdout.log" local stderr_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-stderr.log" local env_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-env.log" @@ -162,7 +163,8 @@ run_test_command() { sh packages/claude-code/bin/claude $cmd_args \ > "$stdout_file" 2> "$stderr_file" || exit_code=$? - local cmd_end=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + local cmd_end + cmd_end=$(date -u '+%Y-%m-%dT%H:%M:%SZ') # メタ情報に終了時刻とexit codeを追記 { @@ -172,8 +174,10 @@ run_test_command() { } >> "$meta_file" # stdout/stderr の行数を記録 - local stdout_lines=$(wc -l < "$stdout_file" 2>/dev/null || echo 0) - local stderr_lines=$(wc -l < "$stderr_file" 2>/dev/null || echo 0) + local stdout_lines + stdout_lines=$(wc -l < "$stdout_file" 2>/dev/null || echo 0) + local stderr_lines + stderr_lines=$(wc -l < "$stderr_file" 2>/dev/null || echo 0) echo " Exit code: $exit_code (expected: $expected_exit_code)" echo " Stdout lines: $stdout_lines" @@ -344,10 +348,10 @@ EOF # 4つのコマンドの結果を記録 for cmd_num in 1 2 3 4; do case $cmd_num in - 1) cmd_args="--version"; cmd_name="version"; expected_code=0 ;; - 2) cmd_args="--help"; cmd_name="help"; expected_code=0 ;; - 3) cmd_args="--nonexistent-flag"; cmd_name="nonexistent-flag"; expected_code=1 ;; - 4) cmd_args="doctor"; cmd_name="doctor"; expected_code=0 ;; + 1) cmd_args="--version"; cmd_name="version" ;; + 2) cmd_args="--help"; cmd_name="help" ;; + 3) cmd_args="--nonexistent-flag"; cmd_name="nonexistent-flag" ;; + 4) cmd_args="doctor"; cmd_name="doctor" ;; esac meta_file="$LOGS_DIR/cmd${cmd_num}-${cmd_name}-meta.log"