Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/installationDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ async function extractVersionFromJsFile(cliPath: string): Promise<string> {
async function extractVersionFromNativeBinary(
binaryPath: string
): Promise<string> {
const claudeJsBuffer =
const { data: claudeJsBuffer } =
await extractClaudeJsFromNativeInstallation(binaryPath);

if (!claudeJsBuffer) {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { Installation } from './types';
*/
export async function readContent(installation: Installation): Promise<string> {
if (installation.kind === 'native') {
const buffer = await extractClaudeJsFromNativeInstallation(
const { data: buffer } = await extractClaudeJsFromNativeInstallation(
installation.path
);
if (!buffer) {
Expand Down Expand Up @@ -61,7 +61,8 @@ export async function writeContent(
await repackNativeInstallation(
installation.path,
modifiedBuffer,
installation.path
installation.path,
false
);
} else {
await replaceFileBreakingHardLinks(installation.path, content, 'patch');
Expand Down
108 changes: 94 additions & 14 deletions src/nativeInstallation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/

import fs from 'node:fs';
import { execSync } from 'node:child_process';
import path from 'node:path';
import os from 'node:os';
import { execSync, execFileSync } from 'node:child_process';
import LIEF from 'node-lief';
import { isDebug, debug } from './utils';

Expand Down Expand Up @@ -151,6 +153,7 @@ export function resolveNixBinaryWrapper(binaryPath: string): string | null {
* - flags: u32
*/
const BUN_TRAILER = Buffer.from('\n---- Bun! ----\n');
const BUN_BYTECODE_PREFIX = '// @bun @bytecode';

// Size constants for binary structures
const SIZEOF_OFFSETS = 32;
Expand Down Expand Up @@ -701,9 +704,59 @@ function getBunData(
* real binary path here. This is handled at detection time in
* `installationDetection.ts`.
*/
function fetchNpmSource(version: string): Buffer | null {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tweakcc-npm-'));
try {
debug(`fetchNpmSource: Downloading @anthropic-ai/claude-code@${version}`);
execFileSync(
'npm',
[
'pack',
`@anthropic-ai/claude-code@${version}`,
'--pack-destination',
tmpDir,
],
{ stdio: 'pipe', timeout: 30_000, cwd: tmpDir }
);

const files = fs.readdirSync(tmpDir);
const tgz = files.find(f => f.endsWith('.tgz'));
if (!tgz) {
debug('fetchNpmSource: No .tgz file found after npm pack');
return null;
}

execFileSync('tar', ['xzf', path.join(tmpDir, tgz), 'package/cli.js'], {
stdio: 'pipe',
timeout: 30_000,
cwd: tmpDir,
});

const cliJsPath = path.join(tmpDir, 'package', 'cli.js');
if (!fs.existsSync(cliJsPath)) {
debug('fetchNpmSource: cli.js not found in extracted package');
return null;
}

const content = fs.readFileSync(cliJsPath);
debug(`fetchNpmSource: Got cli.js, ${content.length} bytes`);
return content;
} catch (error) {
debug('fetchNpmSource: Failed to fetch npm source:', error);
return null;
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
}

export function extractClaudeJsFromNativeInstallation(
nativeInstallationPath: string
): Buffer | null {
nativeInstallationPath: string,
version?: string
): { data: Buffer | null; clearBytecode: boolean } {
try {
LIEF.logging.disable();
const binary = LIEF.parse(nativeInstallationPath);
Expand All @@ -722,9 +775,6 @@ export function extractClaudeJsFromNativeInstallation(
`extractClaudeJsFromNativeInstallation: Module ${index}: ${moduleName}`
);

// Module name is typically:
// - Unix/macOS: /$bunfs/root/claude
// - Windows: B:/~BUN/root/claude.exe
if (!isClaudeModule(moduleName)) return undefined;

const moduleContents = getStringPointerContent(
Expand All @@ -741,29 +791,54 @@ export function extractClaudeJsFromNativeInstallation(
);

if (result) {
return result;
const head = result.subarray(0, 30).toString('utf8');
if (head.startsWith(BUN_BYTECODE_PREFIX)) {
debug(
'extractClaudeJsFromNativeInstallation: Extracted content is Bun bytecode — falling back to npm source'
);

if (version) {
const npmSource = fetchNpmSource(version);
if (npmSource) {
debug(
`extractClaudeJsFromNativeInstallation: Using npm source (${npmSource.length} bytes) instead of bytecode`
);
return { data: npmSource, clearBytecode: true };
}
debug(
'extractClaudeJsFromNativeInstallation: npm source fetch failed, returning bytecode content as-is'
);
} else {
debug(
'extractClaudeJsFromNativeInstallation: No version provided, cannot fetch npm source'
);
}
}

return { data: result, clearBytecode: false };
}

debug(
'extractClaudeJsFromNativeInstallation: claude module not found in any module'
);

return null;
return { data: null, clearBytecode: false };
} catch (error) {
debug(
'extractClaudeJsFromNativeInstallation: Error during extraction:',
error
);

return null;
return { data: null, clearBytecode: false };
}
}

function rebuildBunData(
bunData: Buffer,
bunOffsets: BunOffsets,
modifiedClaudeJs: Buffer | null,
moduleStructSize: number
moduleStructSize: number,
clearBytecode: boolean
): Buffer {
// Phase 1: Collect all string data
const stringsData: Buffer[] = [];
Expand All @@ -786,14 +861,18 @@ function rebuildBunData(

// Check if this is claude.js and we have modified contents
let contentsBytes: Buffer;
let bytecodeBytes: Buffer;
if (modifiedClaudeJs && isClaudeModule(moduleName)) {
contentsBytes = modifiedClaudeJs;
bytecodeBytes = clearBytecode
? Buffer.alloc(0)
: getStringPointerContent(bunData, module.bytecode);
} else {
contentsBytes = getStringPointerContent(bunData, module.contents);
bytecodeBytes = getStringPointerContent(bunData, module.bytecode);
}

const sourcemapBytes = getStringPointerContent(bunData, module.sourcemap);
const bytecodeBytes = getStringPointerContent(bunData, module.bytecode);
const moduleInfoBytes = getStringPointerContent(bunData, module.moduleInfo);
const bytecodeOriginPathBytes = getStringPointerContent(
bunData,
Expand Down Expand Up @@ -1392,19 +1471,20 @@ function repackELFOverlay(
export function repackNativeInstallation(
binPath: string,
modifiedClaudeJs: Buffer,
outputPath: string
outputPath: string,
clearBytecode: boolean
): void {
LIEF.logging.disable();
const binary = LIEF.parse(binPath);

// Extract Bun data and rebuild with modified claude.js
const { bunOffsets, bunData, sectionHeaderSize, moduleStructSize } =
getBunData(binary);
const newBuffer = rebuildBunData(
bunData,
bunOffsets,
modifiedClaudeJs,
moduleStructSize
moduleStructSize,
clearBytecode
);

switch (binary.format) {
Expand Down
23 changes: 16 additions & 7 deletions src/nativeInstallationLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ async function tryLoadNativeInstallationModule(): Promise<NativeInstallationModu
* Returns null if node-lief is not available or extraction fails.
*/
export async function extractClaudeJsFromNativeInstallation(
nativeInstallationPath: string
): Promise<Buffer | null> {
nativeInstallationPath: string,
version?: string
): Promise<{ data: Buffer | null; clearBytecode: boolean }> {
const mod = await tryLoadNativeInstallationModule();
if (!mod) {
return null;
return { data: null, clearBytecode: false };
}
return mod.extractClaudeJsFromNativeInstallation(nativeInstallationPath);
return mod.extractClaudeJsFromNativeInstallation(
nativeInstallationPath,
version
);
}

/**
Expand All @@ -71,17 +75,22 @@ export async function extractClaudeJsFromNativeInstallation(
export async function repackNativeInstallation(
binPath: string,
modifiedClaudeJs: Buffer,
outputPath: string
outputPath: string,
clearBytecode: boolean
): Promise<void> {
// The module should already be cached from a prior extractClaudeJsFromNativeInstallation() call
const mod = await tryLoadNativeInstallationModule();
if (!mod) {
throw new Error(
'`repackNativeInstallation()` called but `node-lief` is not available. ' +
'This is unexpected - `extractClaudeJsFromNativeInstallation()` should have been called first.'
);
}
mod.repackNativeInstallation(binPath, modifiedClaudeJs, outputPath);
mod.repackNativeInstallation(
binPath,
modifiedClaudeJs,
outputPath,
clearBytecode
);
}

/**
Expand Down
14 changes: 10 additions & 4 deletions src/patches/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ export const applyCustomization = async (
patchFilter?: string[] | null
): Promise<ApplyCustomizationResult> => {
let content: string;
let clearBytecode = false;

if (ccInstInfo.nativeInstallationPath) {
// For native installations: restore the binary, then extract to memory
Expand All @@ -561,14 +562,18 @@ export const applyCustomization = async (
`Extracting claude.js from ${backupExists ? 'backup' : 'native installation'}: ${pathToExtractFrom}`
);

const claudeJsBuffer =
await extractClaudeJsFromNativeInstallation(pathToExtractFrom);
const { data: claudeJsBuffer, clearBytecode: needsClearBytecode } =
await extractClaudeJsFromNativeInstallation(
pathToExtractFrom,
ccInstInfo.version
);

if (!claudeJsBuffer) {
throw new Error('Failed to extract claude.js from native installation');
}

// Save original extracted JS for debugging
clearBytecode = needsClearBytecode;

const origPath = path.join(CONFIG_DIR, 'native-claudejs-orig.js');
fsSync.writeFileSync(origPath, claudeJsBuffer);
debug(`Saved original extracted JS from native to: ${origPath}`);
Expand Down Expand Up @@ -904,7 +909,8 @@ export const applyCustomization = async (
await repackNativeInstallation(
ccInstInfo.nativeInstallationPath,
modifiedBuffer,
ccInstInfo.nativeInstallationPath
ccInstInfo.nativeInstallationPath,
clearBytecode
);
} else {
// For NPM installations: replace the cli.js file
Expand Down
22 changes: 11 additions & 11 deletions src/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ describe('config.ts', () => {
vi.spyOn(
nativeInstallation,
'extractClaudeJsFromNativeInstallation'
).mockResolvedValue(mockJsBuffer);
).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false });

const result = await findClaudeCodeInstallation(mockConfig, {
interactive: true,
Expand Down Expand Up @@ -569,7 +569,7 @@ describe('config.ts', () => {
vi.spyOn(
nativeInstallation,
'extractClaudeJsFromNativeInstallation'
).mockResolvedValue(mockJsBuffer);
).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false });

const result = await findClaudeCodeInstallation(mockConfig, {
interactive: true,
Expand Down Expand Up @@ -693,7 +693,7 @@ describe('config.ts', () => {
vi.spyOn(
nativeInstallation,
'extractClaudeJsFromNativeInstallation'
).mockResolvedValue(mockJsBuffer);
).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false });

const result = await findClaudeCodeInstallation(mockConfig, {
interactive: true,
Expand Down Expand Up @@ -758,7 +758,7 @@ describe('config.ts', () => {
vi.spyOn(
nativeInstallation,
'extractClaudeJsFromNativeInstallation'
).mockResolvedValue(mockJsBuffer);
).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false });

const result = await findClaudeCodeInstallation(mockConfig, {
interactive: true,
Expand Down Expand Up @@ -1131,7 +1131,7 @@ describe('config.ts', () => {
vi.spyOn(
nativeInstallation,
'extractClaudeJsFromNativeInstallation'
).mockResolvedValue(mockJsBuffer);
).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false });

const result = await findClaudeCodeInstallation(mockConfig, {
interactive: true,
Expand Down Expand Up @@ -1323,11 +1323,10 @@ describe('config.ts', () => {
// WASMagic reports binary
mockMagicInstance.detect.mockReturnValue('application/octet-stream');

// Mock native extraction to return null (extraction failed)
vi.spyOn(
nativeInstallation,
'extractClaudeJsFromNativeInstallation'
).mockResolvedValue(null);
).mockResolvedValue({ data: null, clearBytecode: false });

vi.spyOn(fs, 'readFile').mockRejectedValue(createEnoent());

Expand Down Expand Up @@ -1461,10 +1460,12 @@ describe('config.ts', () => {

mockMagicInstance.detect.mockReturnValue('application/octet-stream');

// Mock extractClaudeJsFromNativeInstallation to return content without VERSION
vi.mocked(
nativeInstallation.extractClaudeJsFromNativeInstallation
).mockResolvedValue(Buffer.from('no version here'));
).mockResolvedValue({
data: Buffer.from('no version here'),
clearBytecode: false,
});

// Should throw error since no VERSION found
await expect(
Expand Down Expand Up @@ -1513,10 +1514,9 @@ describe('config.ts', () => {

mockMagicInstance.detect.mockReturnValue('application/octet-stream');

// Mock extractClaudeJsFromNativeInstallation to return null (extraction failed)
vi.mocked(
nativeInstallation.extractClaudeJsFromNativeInstallation
).mockResolvedValue(null);
).mockResolvedValue({ data: null, clearBytecode: false });

// Should throw error since extraction failed
await expect(
Expand Down