diff --git a/AGENTS.md b/AGENTS.md index 8a6fa1e..c08bc61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,12 +26,20 @@ src/ │ │ ├── providers/ # Per-provider overlays (zai.ts, minimax.ts) │ │ ├── overlays.ts # Overlay resolution │ │ └── targets.ts # Target file mapping -│ └── *.ts # Utils (paths, fs, tweakcc, skills, etc.) +│ ├── binary-patcher/ # In-repo Bun-binary patcher (replaces tweakcc CLI) +│ │ ├── index.ts # applyPatches orchestrator +│ │ ├── theme.ts # Theme color anchor + rewrite (ported from tweakcc, MIT) +│ │ ├── prompts.ts # Per-OverlayKey tail-anchor splice for prompt overlays +│ │ ├── replace-entry.ts # Resize-capable entry-module replacement +│ │ ├── repack.ts # Cross-platform container rewrite dispatcher +│ │ ├── {macho,elf,pe}-resize.ts # Per-platform header rewrites +│ │ └── codesign.ts # macOS ad-hoc signing helper +│ └── *.ts # Utils (paths, fs, install, skills, etc.) ├── providers/ # Provider templates │ └── index.ts # Provider definitions and defaults -├── brands/ # TweakCC brand presets +├── brands/ # Brand theme presets (one per provider) │ ├── index.ts # Brand resolution -│ ├── types.ts # TweakCC config types +│ ├── types.ts # Theme config types (legacy "TweakccConfig" name kept) │ ├── zai.ts # Z.ai theme + blocked tools │ ├── minimax.ts # MiniMax theme + blocked tools │ └── *.ts # Other brand configs @@ -42,10 +50,11 @@ test/ # Node test runner tests ├── unit/ # Unit tests └── helpers/ # Test utilities -repos/ # Upstream reference copies (vendor data, history) +repos/ # Upstream reference copies (vendored, gitignored) ├── anthropic-claude-code-*/ # Claude Code versions for comparison/reference ├── claude-code-system-prompts/ # System prompt changelog and sources -└── tweakcc/ # TweakCC repo (prompt patching tool) +└── tweakcc/ # tweakcc CLI (reference for binary-patcher anchors) + # Refresh via scripts/vendor-tweakcc.sh notes/ # Research notes and deep dive documentation ├── CLI-VERSIONS.md # Version comparison notes @@ -85,8 +94,9 @@ npm run render:tui-svg # Regenerate docs/cc-mirror-tree.svg │ ├── settings.json # Env overrides (API keys, base URLs, model defaults) │ ├── .claude.json # API-key approvals + onboarding/theme + MCP servers ├── tweakcc/ -│ ├── config.json # Brand preset + theme list + toolsets -│ └── system-prompts/ # Prompt-pack overlays (after tweakcc apply) +│ └── config.json # Brand preset + theme list (read by the patcher +│ # via ensureTweakccConfig; directory name kept for +│ # back-compat with existing variants) ├── native/ │ └── claude # Native Claude Code binary (or claude.exe on Windows) └── variant.json # Metadata @@ -148,8 +158,10 @@ export const MINIMAX_BLOCKED_TOOLS = [ - Only `minimal` mode supported (maximal deprecated) - Per-provider overlays in `src/core/prompt-pack/providers/` -- Applied to `tweakcc/system-prompts/` via TweakCC -- Overlays are sanitized to strip backticks (tweakcc template literal issue) +- Applied directly to the bundled cli.js by `src/core/binary-patcher/prompts.ts` + (per-OverlayKey tail-anchor splice; see `prompts.ts` for the anchor table) +- Overlay text is escaped for the detected string delimiter (template literal + vs single/double quote) at splice time ## Common Development Tasks @@ -172,7 +184,7 @@ cat ~/.cc-mirror//config/settings.json cat ~/.cc-mirror//config/.claude.json cat ~/.cc-mirror//variant.json -# TweakCC config +# Brand/theme config (read by the in-repo patcher) cat ~/.cc-mirror//tweakcc/config.json # Wrapper script @@ -190,15 +202,15 @@ npx cc-mirror doctor - **Upstream CLI references**: `repos/anthropic-claude-code-*/cli.js` (multiple versions for comparison) - **System prompt sources**: `repos/claude-code-system-prompts/` (includes CHANGELOG.md) - **Research notes**: `notes/` (deep dives, version analysis, design decisions) -- **Applied prompts**: `~/.cc-mirror//tweakcc/system-prompts/` - **Debug logs**: `~/.cc-mirror//config/debug/*.txt` ### CLI Feature Gates ```bash -# Native installs don't have cli.js on disk. Use tweakcc to unpack first: -npx tweakcc unpack /tmp/claude-code.js ~/.cc-mirror//native/claude -rg "tengu_prompt_suggestion|promptSuggestionEnabled" /tmp/claude-code.js +# Native installs don't have cli.js on disk. Use cc-mirror unpack to extract +# the bundled JS modules from the Bun standalone binary: +npx cc-mirror unpack ~/.cc-mirror//native/claude --out /tmp/claude-unpacked +rg "tengu_prompt_suggestion|promptSuggestionEnabled" /tmp/claude-unpacked/ # Check cached gates cat ~/.cc-mirror//config/.claude.json | jq '.statsig' @@ -248,7 +260,10 @@ Key test files: ## Architecture Notes - **Step-based builds**: Each step is isolated, can be sync or async -- **Build order**: PrepareDirectories → InstallNative → WriteConfig → BrandTheme → Tweakcc → Wrapper → ShellEnv → SkillInstall → Finalize +- **Build order**: PrepareDirectories → InstallNative → WriteConfig → BrandTheme → BinaryPatcher → Wrapper → ShellEnv → SkillInstall → Finalize +- **Binary patcher**: in-repo theme + prompt overlay applier. Replaces the + earlier `npx tweakcc` shell-out. See `src/core/binary-patcher/index.ts` + (`applyPatches`) and `THIRD_PARTY_NOTICES.md` for upstream attribution. ## Documentation diff --git a/README.md b/README.md index bb3f363..b7335f2 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ At its core, CC-MIRROR: 1. **Clones** Claude Code into isolated instances 2. **Configures** provider endpoints, model mapping, and env defaults -3. **Applies** prompt packs and tweakcc themes +3. **Applies** brand themes and provider prompt overlays via the in-repo binary patcher 4. **Installs** optional skills (dev-browser, opt-in) 5. **Packages** everything into a single command @@ -85,7 +85,7 @@ Each variant is completely isolated — its own config, sessions, MCP servers, a │ ├── mclaude/ ← Mirror Claude │ │ │ ├── native/ Claude Code installation │ │ │ ├── config/ API keys, sessions, MCP servers │ -│ │ ├── tweakcc/ Theme customization │ +│ │ ├── tweakcc/ Brand theme + overlay config │ │ │ └── variant.json Metadata │ │ │ │ │ ├── zai/ ← Z.ai variant (GLM models) │ @@ -190,10 +190,9 @@ npx cc-mirror quick [options] # Fast setup with defaults npx cc-mirror create [options] # Full configuration wizard npx cc-mirror list # List all variants npx cc-mirror update [name] # Update one or all variants -npx cc-mirror apply # Re-apply tweakcc patches (no reinstall) +npx cc-mirror apply # Re-apply theme + prompt patches (no reinstall) npx cc-mirror remove # Delete a variant npx cc-mirror doctor # Health check all variants -npx cc-mirror tweak # Launch tweakcc customization # Launch your variant mclaude # Run Mirror Claude @@ -215,16 +214,20 @@ kimi # Run Kimi Code variant --model-opus Map to opus model --model-haiku Map to haiku model --brand Theme: auto | kimi | minimax | zai | openrouter | vercel | ollama | nanogpt | ccrouter | mirror | gatewayz ---no-tweak Skip tweakcc theme +--no-tweak Skip brand theme + prompt overlay patches --no-prompt-pack Skip provider prompt pack ---verbose Show full tweakcc output during update +--verbose Show full patcher output during update ``` --- ## Brand Themes -Each provider includes a custom color theme via [tweakcc](https://github.com/Piebald-AI/tweakcc): +Each provider includes a custom color theme applied by cc-mirror's in-repo +binary patcher (anchor patterns adapted from [tweakcc](https://github.com/Piebald-AI/tweakcc) +under MIT — see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)). On macOS, +themes are currently disabled (Mach-O segment shifting not yet implemented); +linux and Windows variants get the full theme. | Brand | Style | | -------------- | -------------------------------- | @@ -252,7 +255,7 @@ Each provider includes a custom color theme via [tweakcc](https://github.com/Pie ## Related Projects -- [tweakcc](https://github.com/Piebald-AI/tweakcc) — Theme and customize Claude Code +- [tweakcc](https://github.com/Piebald-AI/tweakcc) — Theme and customize Claude Code (cc-mirror's binary-patcher anchors are adapted from this project under MIT) - [Claude Code Router](https://github.com/musistudio/claude-code-router) — Route Claude Code to any LLM - [n-skills](https://github.com/numman-ali/n-skills) — Universal skills for AI agents @@ -262,7 +265,7 @@ Each provider includes a custom color theme via [tweakcc](https://github.com/Pie Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup. -**Want to add a provider?** Check the [Provider Guide](docs/TWEAKCC-GUIDE.md). +**Want to add a provider?** Read [AGENTS.md](AGENTS.md) for the codebase layout and the per-provider extension points. --- diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..8f1a6cd --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,42 @@ +# Third-party notices + +This project ports code from upstream open-source projects under their original +licenses. The vendored sources live in `repos/` (gitignored — refresh via +`scripts/vendor-tweakcc.sh`); attribution and license text live here. + +--- + +## tweakcc + +- Upstream: https://github.com/Piebald-AI/tweakcc +- Vendored at commit: `303b7560290679127f3d32a6e42c66272d6f0c01` +- License: MIT + +cc-mirror ports the binary-patching anchor patterns and minified-name finders +from tweakcc into `src/core/binary-patcher/`. Each ported file carries an +`// Adapted from tweakcc src/` header. The upstream MIT terms +follow: + +``` +MIT License + +Copyright (c) 2025 Piebald LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/docs/TWEAKCC-GUIDE.md b/docs/TWEAKCC-GUIDE.md deleted file mode 100644 index 461723e..0000000 --- a/docs/TWEAKCC-GUIDE.md +++ /dev/null @@ -1,216 +0,0 @@ -# tweakcc Integration Guide (cc-mirror) - -This document summarizes tweakcc capabilities and concrete implementation ideas for cc-mirror variants. It is intentionally practical: you can copy/paste snippets, adopt patterns, or expand into your own presets. - -## What tweakcc can do (from upstream README) - -- Edit Claude Code system prompts (core prompt, tool descriptions, agent prompts, utilities, etc.) -- Create custom themes (RGB/HSL picker), and switch themes in Claude Code -- Create and manage toolsets (and use them via Claude Code’s `/toolset`) -- Style user messages (custom label, borders, colors, padding) -- Customize thinking verbs and spinner animation (phases + speed) -- Remove the input box border -- Expand thinking blocks by default -- Enable session naming commands like `/title` and `/rename` -- Configure subagent models (Plan/Explore/etc.) -- Input pattern highlighters (highlight keywords while typing) -- Table format toggles (Unicode/ASCII/markdown styles) -- Session memory and “remember” features (optional) -- Suppress installer warnings (useful when you manage Claude installs manually) -- Misc UX patches (hide startup banner, hide clawd logo, hide “Ctrl+G to edit prompt” hint, etc.) -- A large library of built-in patches, plus advanced `unpack`/`repack`/`adhoc-patch` workflows - -tweakcc can patch either: - -- **npm-based installs** (patches `cli.js` directly), or -- **native installs** (extracts the bundled JS from the `claude` binary, patches it, then repacks the binary). - -## How cc-mirror uses tweakcc - -- Per-variant config lives at: - - `~/.cc-mirror//tweakcc/config.json` - - `~/.cc-mirror//tweakcc/system-prompts/` -- Patch apply uses: - - `TWEAKCC_CONFIG_DIR=~/.cc-mirror//tweakcc` - - `TWEAKCC_CC_INSTALLATION_PATH=...` (cc-mirror uses the native `~/.cc-mirror//native/claude` binary) -- cc-mirror applies tweakcc after create/update, unless `--no-tweak`. - - To re-apply patches without reinstalling Claude Code, run: `npx cc-mirror apply ` -- cc-mirror pins the tweakcc CLI version it runs (see `src/core/constants.ts`) so normal behavior stays reproducible. -- If the pinned tweakcc version cannot extract a newer Claude Code native binary, cc-mirror automatically retries with `tweakcc@latest` before failing. -- You can still manually run a different version via `npx tweakcc@latest` if you need to inspect or hotfix a brand-new Claude Code release yourself. - -### Tool restrictions (no toolsets) - -cc-mirror intentionally does **not** manage tweakcc toolsets. - -Instead, provider-specific tool restrictions (for example blocking `WebSearch` so the provider MCP can be used) are enforced via Claude Code's native `settings.json`: - -- `~/.cc-mirror//config/settings.json` → `permissions.deny` - -This keeps the UX stable and avoids relying on UI-level toolset patches inside the `claude` binary. - -## Manual tweakcc for a single variant - -Use this when you want to manually enable optional tweakcc features (for example swarm mode or session memory) on one variant without adding new cc-mirror UI settings. - -### Fast path (recommended) - -```bash -npx cc-mirror tweak -``` - -### Direct path (explicit target) - -```bash -VARIANT= -TWEAKCC_CONFIG_DIR="$HOME/.cc-mirror/$VARIANT/tweakcc" \ -TWEAKCC_CC_INSTALLATION_PATH="$HOME/.cc-mirror/$VARIANT/native/claude" \ -npx tweakcc@4.0.11 -``` - -### Apply specific optional patches - -```bash -VARIANT= -TWEAKCC_CONFIG_DIR="$HOME/.cc-mirror/$VARIANT/tweakcc" \ -TWEAKCC_CC_INSTALLATION_PATH="$HOME/.cc-mirror/$VARIANT/native/claude" \ -npx tweakcc@4.0.11 --apply --patches "," -``` - -Patch names depend on your tweakcc version. Run `npx tweakcc@4.0.11 --help` (or open the tweakcc UI patch list) to confirm available patch IDs. - -## Recommended implementation patterns - -### 1) Theme design (brand identity) - -Goal: make each provider unmistakable while keeping readability. - -Implementation suggestions: - -- Choose a single signature accent color ("claude"/"bashBorder") and 1-2 supporting colors. -- Keep `background` and `text` high-contrast (use light backgrounds only if your terminal supports it). -- Use muted tinted backgrounds for: - - `userMessageBackground` - - `bashMessageBackgroundColor` - - `memoryBackgroundColor` -- Keep `promptBorder` and `promptBorderShimmer` slightly darker than background, so focus rings show. - -Example snippet (light theme with strong accents): - -``` -{ - "name": "MiniMax Pulse", - "id": "minimax-pulse", - "colors": { - "claude": "rgb(255,77,77)", - "claudeShimmer": "rgb(255,140,140)", - "background": "rgb(245,245,245)", - "text": "rgb(17,17,17)", - "promptBorder": "rgb(229,209,255)", - "userMessageBackground": "rgb(255,235,240)" - } -} -``` - -### 2) User message display (chat banner) - -Make the user label obvious and brand-consistent. - -Suggested settings: - -- `format`: ` [] {}` -- `borderStyle`: `topBottomBold` or `topBottomDouble` -- `fitBoxToContent`: `true` - -### 3) Thinking verbs + spinner - -Make the "thinking" feel unique. - -Ideas: - -- Short, punchy verbs for fast models ("Routing", "Syncing") -- Longer verbs for more "deliberate" feel ("Calibrating", "Synthesizing") -- Spinner phases like `['·','•','◦','•']` for clean minimal rhythm - -### 4) Input box + misc UX - -Tweakcc can simplify the UI: - -``` -"inputBox": { "removeBorder": true }, - "misc": { - "showPatchesApplied": true, - "hideStartupBanner": false, - "hideStartupClawd": false, - "expandThinkingBlocks": true, - "hideCtrlGToEdit": true -} -``` - -### 5) System prompts (advanced) - -System prompt editing is powerful but risky. Suggested process: - -- Start by editing only one prompt (core prompt) and validate behavior. -- Keep diffs small; avoid removing safety or tool instructions. -- When Claude Code updates, tweakcc will create HTML diffs for conflicts. - -Suggested workflow: - -1. Run tweakcc UI or open the system prompts folder. -2. Edit a single prompt file. -3. Run `tweakcc --apply` (cc-mirror does this on update). - -### 6) Context limit overrides - -Use `CLAUDE_CODE_CONTEXT_LIMIT` only for custom endpoints that support larger windows. - -- Example: `CLAUDE_CODE_CONTEXT_LIMIT=400000` - -### 7) Version compatibility and patch warnings - -- tweakcc is sensitive to Claude Code versions. Patch failures are expected after CC updates. -- The `tweakcc` UI will still work even when one patch fails. - -## Recommended cc-mirror UX flows - -### Quick path (simple install) - -- Prompt for API key -- Create variant (native install, pinned version) -- Apply brand preset + tweakcc patches -- Exit - -### Advanced path - -- Choose brand preset -- Optionally open tweakcc UI -- Optionally edit system prompts - -## Checklist for creating a polished brand preset - -- [ ] Unique theme palette with high contrast -- [ ] Distinct thinking verbs + spinner style -- [ ] User message banner formatting -- [ ] Input box border removed -- [ ] Startup banner visibility (hide or show) -- [ ] System prompt customized (optional) - -## Startup ASCII art - -tweakcc can **show or hide** Claude Code’s built‑in startup banner and clawd art. It does not currently support **custom** startup ASCII art. cc-mirror can optionally print a small wrapper splash when `CC_MIRROR_SPLASH=1`, and skips it for non‑TTY output or `--output-format` runs. - -## Where to look in this repo - -- Brand themes: `src/brands/*.ts` -- Tweakcc config writing: `src/core/tweakcc.ts` -- Variant creation: `src/core/index.ts` -- tweakcc upstream reference: `repos/tweakcc/README.md` - -## Suggested roadmap (next steps) - -1. **Theme polish pass**: iterate on one brand at a time, validate contrast, and tune borders/shimmers. -2. **System prompt v1**: edit only the main system prompt + 1 tool description, then validate behavior. -3. **MCP defaults**: seed provider MCP servers in `.claude.json`, then document how to manage scopes. -4. **Update flow**: add a “reapply after CC update” hint in CLI/TUI and detect patch failures early. -5. **UX polish**: add short “preview” messages in the TUI showing what will change before applying. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 43bb613..46358a9 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -116,7 +116,7 @@ src/ │ │ │ │ │ │ │ 4. BrandThemeStep Write tweakcc/config.json │ │ │ │ │ │ │ -│ │ 5. TweakccStep Apply customization via tweakcc │ │ +│ │ 5. BinaryPatcherStep In-repo theme + prompt overlay patcher │ │ │ │ │ │ │ │ │ 6. WrapperStep Create / │ │ │ │ │ │ │ @@ -149,7 +149,7 @@ npx cc-mirror update │ 1. RebuildUpdateStep Reset claude/tweakcc dirs (keep config) │ │ 2. InstallNativeUpdateStep Download + verify native CC (unless settingsOnly) │ │ 3. ModelOverridesStep Update model mappings │ -│ 4. TweakccUpdateStep Re-apply theme │ +│ 4. BinaryPatcherUpdateStep Re-apply theme + prompt overlays │ │ 5. WrapperUpdateStep Regenerate wrapper script │ │ 6. ConfigUpdateStep Update settings.json │ │ 7. ShellEnvUpdateStep Update shell env integration │ @@ -175,10 +175,8 @@ npx cc-mirror update │ │ ├── settings.json Env vars (API keys, base URLs) │ │ │ ├── .claude.json MCP servers, approvals, onboarding │ │ │ │ -│ ├── tweakcc/ tweakcc configuration │ -│ │ ├── config.json Theme and UI customization │ -│ │ ├── cli.js.backup tweakcc-managed backup │ -│ │ └── system-prompts/ Prompt pack overlays │ +│ ├── tweakcc/ Brand theme + UI customization config │ +│ │ └── config.json Read by the in-repo binary patcher │ │ │ │ │ └── variant.json Variant metadata │ │ │ diff --git a/scripts/vendor-tweakcc.sh b/scripts/vendor-tweakcc.sh new file mode 100755 index 0000000..0aa137d --- /dev/null +++ b/scripts/vendor-tweakcc.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Vendor upstream tweakcc into repos/tweakcc for reference. +# +# repos/ is gitignored. We never commit tweakcc; we just pin the commit SHA +# of whatever we ported from in THIRD_PARTY_NOTICES.md so a reviewer can +# diff our ports against a known upstream revision. +# +# Refresh by deleting repos/tweakcc and rerunning this script. + +set -euo pipefail + +REPO_URL="https://github.com/Piebald-AI/tweakcc.git" +TARGET_DIR="repos/tweakcc" +REF="${1:-main}" + +if [ -d "$TARGET_DIR/.git" ]; then + echo "tweakcc already vendored at $TARGET_DIR" >&2 + echo "delete it and rerun if you want a fresh checkout" >&2 + exit 0 +fi + +mkdir -p repos +git clone --depth=1 --branch "$REF" "$REPO_URL" "$TARGET_DIR" + +cd "$TARGET_DIR" +SHA=$(git rev-parse HEAD) +echo +echo "Vendored tweakcc at $SHA" +echo "Update THIRD_PARTY_NOTICES.md with this SHA when porting code." diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 1f73822..b93b209 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -5,7 +5,7 @@ export { runListCommand, type ListCommandOptions } from './list.js'; export { runDoctorCommand, type DoctorCommandOptions } from './doctorCmd.js'; export { runRemoveCommand, type RemoveCommandOptions } from './remove.js'; -export { runTweakCommand, type TweakCommandOptions } from './tweak.js'; export { runUpdateCommand, type UpdateCommandOptions } from './update.js'; export { runApplyCommand, type ApplyCommandOptions } from './apply.js'; export { runCreateCommand, type CreateCommandOptions } from './create.js'; +export { runUnpackCommand, type UnpackCommandOptions } from './unpack.js'; diff --git a/src/cli/commands/tweak.ts b/src/cli/commands/tweak.ts deleted file mode 100644 index f4d65c2..0000000 --- a/src/cli/commands/tweak.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Tweak command - launches tweakcc for a variant - */ - -import * as core from '../../core/index.js'; -import type { ParsedArgs } from '../args.js'; - -export interface TweakCommandOptions { - opts: ParsedArgs; -} - -/** - * Execute the tweak command - */ -export function runTweakCommand({ opts }: TweakCommandOptions): void { - const target = opts._ && opts._[0]; - if (!target) { - console.error('tweak requires a variant name'); - process.exit(1); - } - const rootDir = (opts.root as string) || core.DEFAULT_ROOT; - core.tweakVariant(rootDir, target); -} diff --git a/src/cli/commands/unpack.ts b/src/cli/commands/unpack.ts new file mode 100644 index 0000000..9d5120e --- /dev/null +++ b/src/cli/commands/unpack.ts @@ -0,0 +1,84 @@ +/** + * Unpack command - extract embedded modules from a Bun-compiled Claude Code binary. + * + * Replaces the AGENTS.md `npx tweakcc unpack` workflow with a built-in extractor + * that does not depend on tweakcc/node-lief and survives Bun module-table format + * changes (currently 36 vs 52-byte CompiledModuleGraphFile). + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import * as core from '../../core/index.js'; +import { BunFormatError, extractAll, parseBunBinary } from '../../core/bun-extract.js'; +import { expandTilde } from '../../core/paths.js'; +import { loadVariantMeta } from '../../core/variants.js'; +import type { ParsedArgs } from '../args.js'; + +export interface UnpackCommandOptions { + opts: ParsedArgs; +} + +const resolveBinaryPath = (target: string, rootDir: string): { binaryPath: string; label: string } => { + // Variant name lookup first - matches `tweak ` semantics. + const variantDir = path.join(rootDir, target); + const meta = loadVariantMeta(variantDir); + if (meta && fs.existsSync(meta.binaryPath)) { + return { binaryPath: meta.binaryPath, label: target }; + } + + // Otherwise treat as a filesystem path. + const expanded = expandTilde(target) ?? target; + if (!fs.existsSync(expanded)) { + throw new Error(`No variant or binary found at "${target}"`); + } + return { binaryPath: expanded, label: path.basename(expanded) }; +}; + +export const runUnpackCommand = ({ opts }: UnpackCommandOptions): void => { + const target = opts._?.[0]; + if (!target) { + console.error('unpack requires a variant name or binary path'); + process.exit(1); + } + + const rootDir = expandTilde((opts.root as string) || core.DEFAULT_ROOT) ?? core.DEFAULT_ROOT; + const { binaryPath, label } = resolveBinaryPath(target, rootDir); + + const outOpt = typeof opts.out === 'string' ? opts.out : null; + const outDir = outOpt ? (expandTilde(outOpt) ?? outOpt) : path.resolve(`extracted-${label}`); + const includeSourcemaps = Boolean(opts['include-sourcemaps']); + const writeManifest = opts.manifest !== false; + + const buf = fs.readFileSync(binaryPath); + console.log(`Reading: ${binaryPath} (${(buf.length / 1024 / 1024).toFixed(1)} MB)`); + + let info; + try { + info = parseBunBinary(buf); + } catch (err) { + if (err instanceof BunFormatError) { + console.error(`Failed to parse Bun binary: ${err.message}`); + process.exit(1); + } + throw err; + } + + const entryName = info.modules[info.entryPointId]?.name ?? ''; + console.log( + `Platform: ${info.platform} ModuleSize: ${info.moduleSize} (${info.bunVersionHint}) Modules: ${info.modules.length} Entry: ${entryName}` + ); + if (info.platform === 'macho' && info.hasCodeSignature) { + console.log(' (Mach-O code signature detected; in-place modifications would invalidate it.)'); + } + + const result = extractAll(buf, info, outDir, { + writeSourcemaps: includeSourcemaps, + manifest: writeManifest, + }); + + console.log(`\nWrote ${result.written.length} files to ${outDir}`); + if (result.manifestPath) { + console.log(`Manifest: ${result.manifestPath}`); + } +}; diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index c33e1b6..0615141 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -11,5 +11,19 @@ export const printDoctor = (report: DoctorReportItem[]) => { console.log(` binary: ${item.binaryPath ?? 'missing'}`); console.log(` wrapper: ${item.wrapperPath}`); } + if (item.bunInfo) { + const b = item.bunInfo; + if (b.error) { + console.log(` bun: parse failed - ${b.error}`); + } else { + const parts = [ + b.platform, + `${b.moduleCount} modules`, + b.entryPath ? `entry=${b.entryPath}` : null, + b.bunVersionHint, + ].filter(Boolean); + console.log(` bun: ${parts.join(', ')}`); + } + } } }; diff --git a/src/cli/help.ts b/src/cli/help.ts index 71ef524..89f2c12 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -27,10 +27,10 @@ COMMANDS create [options] Full configuration wizard list List all variants update [name] Update Claude Code (default: latest) - apply Re-apply tweakcc patches (no reinstall) + apply Re-apply theme + prompt patches (no reinstall) remove Remove a variant doctor Health check all variants - tweak Launch tweakcc customization + unpack Extract embedded JS modules from a Claude Code binary OPTIONS (create/quick) --name Variant name (becomes CLI command) @@ -50,10 +50,10 @@ OPTIONS (advanced) --model-haiku Default Haiku model --root Variants root (default: ${DEFAULT_ROOT}) --bin-dir Wrapper install dir (default: ${DEFAULT_BIN_DIR}) - --no-tweak Skip tweakcc theming + --no-tweak Skip brand theme + prompt overlay patches --no-prompt-pack Skip provider prompt pack --shell-env Write env vars to shell profile - --verbose Show full tweakcc output during update + --verbose Show full patcher output during update --json Machine-readable output (list/doctor) --full Verbose output (list) --live Live provider probe (doctor) diff --git a/src/cli/index.ts b/src/cli/index.ts index c7b26a9..59afcca 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,10 +9,10 @@ import { runListCommand, runDoctorCommand, runRemoveCommand, - runTweakCommand, runUpdateCommand, runApplyCommand, runCreateCommand, + runUnpackCommand, } from './commands/index.js'; const main = async () => { @@ -70,8 +70,8 @@ const main = async () => { runRemoveCommand({ opts }); break; - case 'tweak': - runTweakCommand({ opts }); + case 'unpack': + runUnpackCommand({ opts }); break; case 'create': diff --git a/src/core/binary-patcher/codesign.ts b/src/core/binary-patcher/codesign.ts new file mode 100644 index 0000000..71d464f --- /dev/null +++ b/src/core/binary-patcher/codesign.ts @@ -0,0 +1,61 @@ +/** + * Ad-hoc macOS code-signing helper. + * + * Used after a Mach-O patch invalidates the Apple-issued signature: we strip + * the LC_CODE_SIGNATURE in repackMacho, then run `codesign --force --sign -` + * here to reapply an ad-hoc signature so AMFI on Apple Silicon doesn't kill + * the binary. If `codesign` isn't available (CI without Xcode CLT), we leave + * the binary unsigned and let the orchestrator surface a soft warning - the + * smoke test catches anything actually broken. + */ + +import { spawnSync } from 'node:child_process'; + +export interface AdhocSignResult { + signed: boolean; + /** Why we didn't sign, when signed === false. */ + reason?: 'no-codesign' | 'failed'; + detail?: string; +} + +const isCodesignMissingError = (err: NodeJS.ErrnoException | undefined): boolean => { + if (!err) return false; + return err.code === 'ENOENT'; +}; + +export const tryAdhocSign = (binaryPath: string): AdhocSignResult => { + if (process.platform !== 'darwin') { + // Other platforms don't need ad-hoc signing. + return { signed: false, reason: 'no-codesign', detail: `not darwin (process.platform=${process.platform})` }; + } + + let result: ReturnType; + try { + result = spawnSync('codesign', ['--force', '--sign', '-', binaryPath], { + encoding: 'utf8', + windowsHide: true, + }); + } catch (err) { + if (isCodesignMissingError(err as NodeJS.ErrnoException)) { + return { signed: false, reason: 'no-codesign', detail: 'codesign binary not found in PATH' }; + } + return { signed: false, reason: 'failed', detail: (err as Error).message }; + } + + if (result.error && isCodesignMissingError(result.error as NodeJS.ErrnoException)) { + return { signed: false, reason: 'no-codesign', detail: 'codesign binary not found in PATH' }; + } + if (result.error) { + return { signed: false, reason: 'failed', detail: result.error.message }; + } + if (result.status !== 0) { + const stderrRaw = result.stderr; + const stderr = (typeof stderrRaw === 'string' ? stderrRaw : (stderrRaw?.toString('utf8') ?? '')).trim(); + return { + signed: false, + reason: 'failed', + detail: `codesign exited ${result.status}${stderr ? `: ${stderr}` : ''}`, + }; + } + return { signed: true }; +}; diff --git a/src/core/binary-patcher/elf-resize.ts b/src/core/binary-patcher/elf-resize.ts new file mode 100644 index 0000000..c6ba29c --- /dev/null +++ b/src/core/binary-patcher/elf-resize.ts @@ -0,0 +1,183 @@ +/** + * ELF resize: Bun emits a `.bun` PROGBITS section that holds an 8-byte u64 + * size header followed by the standard [rawBytes][offsets(32)][trailer(16)] + * payload. The section header table sits AFTER `.bun` (at file EOF region), + * pointed to by e_shoff in the ELF header. + * + * Resizing requires (each was a real bug found while smoke-testing real CC + * binaries inside docker): + * 1. Swap the old [rawBytes][offsets][trailer] middle for the new bytes. + * 2. Preserve the original ELF tail (section header table + string tables). + * 3. Shift e_shoff (and e_phoff if it lives past the data region) by the + * resize delta so the kernel and ELF tools still find the tables. + * 4. Rewrite the .bun section header's sh_size + the 8-byte u64 size + * header at the section start. Bun's standalone runtime locates the + * embedded payload via the .bun section. + * 5. Grow the PT_LOAD program header that covers .bun: bump p_filesz + + * p_memsz by delta. Without this, the kernel's mmap stops short and + * reads past the original size hit unmapped pages -> SIGBUS / SIGILL. + * The original LOAD has ~one page of slack (filesz rounded up to align) + * so resizes < that slack happen to work, larger ones crash. + */ + +import { MACHO_SECTION_HEADER_SIZE, OFFSETS_SIZE, TRAILER } from '../bun-extract/constants.js'; +import type { BunBinaryInfo } from '../bun-extract.js'; + +const ELF_E_PHOFF = 32; // u64 +const ELF_E_SHOFF = 40; // u64 +const ELF_E_PHENTSIZE = 54; // u16 +const ELF_E_PHNUM = 56; // u16 +const ELF_E_SHENTSIZE = 58; // u16 +const ELF_E_SHNUM = 60; // u16 +const ELF_E_SHSTRNDX = 62; // u16 + +const ELF_SH_NAME = 0; // u32 — index into shstrtab +const ELF_SH_OFFSET = 24; // u64 +const ELF_SH_SIZE = 32; // u64 + +const PT_LOAD = 1; +const ELF_PH_TYPE = 0; // u32 +const ELF_PH_OFFSET = 8; // u64 +const ELF_PH_FILESZ = 32; // u64 +const ELF_PH_MEMSZ = 40; // u64 + +const BUN_SECTION_NAME = '.bun'; + +export interface ElfRepackInputs { + buf: Buffer; + info: BunBinaryInfo; + newRawBytes: Buffer; + newOffsetsStruct: Buffer; +} + +const shiftU64IfPast = (header: Buffer, fieldOffset: number, cutoff: number, delta: number): void => { + const original = header.readBigUInt64LE(fieldOffset); + if (original > BigInt(cutoff)) { + header.writeBigUInt64LE(original + BigInt(delta), fieldOffset); + } +}; + +interface SectionHeaderTable { + /** Absolute file offset of the section header table. */ + fileOff: number; + count: number; + entSize: number; + /** Index of the section name string table. */ + shstrndx: number; +} + +const readSectionHeaderTable = (elfHeader: Buffer): SectionHeaderTable | null => { + if (elfHeader.length < ELF_E_SHSTRNDX + 2) return null; + const fileOff = Number(elfHeader.readBigUInt64LE(ELF_E_SHOFF)); + const entSize = elfHeader.readUInt16LE(ELF_E_SHENTSIZE); + const count = elfHeader.readUInt16LE(ELF_E_SHNUM); + const shstrndx = elfHeader.readUInt16LE(ELF_E_SHSTRNDX); + if (entSize < ELF_SH_SIZE + 8 || count === 0) return null; + return { fileOff, count, entSize, shstrndx }; +}; + +/** + * Bump p_filesz + p_memsz on the PT_LOAD that covers the .bun section. Without + * this, the kernel mmap stops at the old (page-aligned) p_filesz and Bun + * SIGBUSes when it touches the new bytes past the original mapping. + */ +const growPtLoadCoveringSection = (prefix: Buffer, sectionPayloadStart: number, delta: number): void => { + const phoff = Number(prefix.readBigUInt64LE(ELF_E_PHOFF)); + const phentsize = prefix.readUInt16LE(ELF_E_PHENTSIZE); + const phnum = prefix.readUInt16LE(ELF_E_PHNUM); + if (phentsize < ELF_PH_MEMSZ + 8 || phoff + phnum * phentsize > prefix.length) return; + for (let i = 0; i < phnum; i += 1) { + const off = phoff + i * phentsize; + if (prefix.readUInt32LE(off + ELF_PH_TYPE) !== PT_LOAD) continue; + const phOffset = Number(prefix.readBigUInt64LE(off + ELF_PH_OFFSET)); + const phFilesz = prefix.readBigUInt64LE(off + ELF_PH_FILESZ); + if (phOffset > sectionPayloadStart || phOffset + Number(phFilesz) < sectionPayloadStart) continue; + prefix.writeBigUInt64LE(phFilesz + BigInt(delta), off + ELF_PH_FILESZ); + const phMemsz = prefix.readBigUInt64LE(off + ELF_PH_MEMSZ); + prefix.writeBigUInt64LE(phMemsz + BigInt(delta), off + ELF_PH_MEMSZ); + } +}; + +/** Find the .bun section header offset (within the ELF buffer) by walking the table. */ +const findBunSectionHeaderOffset = (buf: Buffer, table: SectionHeaderTable): number | null => { + // Locate shstrtab so we can resolve sh_name -> string. + if (table.shstrndx >= table.count) return null; + const shstrHeaderOff = table.fileOff + table.shstrndx * table.entSize; + if (shstrHeaderOff + table.entSize > buf.length) return null; + const shstrOff = Number(buf.readBigUInt64LE(shstrHeaderOff + ELF_SH_OFFSET)); + const shstrSize = Number(buf.readBigUInt64LE(shstrHeaderOff + ELF_SH_SIZE)); + if (shstrOff + shstrSize > buf.length) return null; + + for (let i = 0; i < table.count; i += 1) { + const headerOff = table.fileOff + i * table.entSize; + if (headerOff + table.entSize > buf.length) return null; + const nameIdx = buf.readUInt32LE(headerOff + ELF_SH_NAME); + if (nameIdx >= shstrSize) continue; + const nameStart = shstrOff + nameIdx; + const nameEnd = buf.indexOf(0, nameStart); + if (nameEnd === -1 || nameEnd > shstrOff + shstrSize) continue; + const name = buf.subarray(nameStart, nameEnd).toString('utf8'); + if (name === BUN_SECTION_NAME) return headerOff; + } + return null; +}; + +export const repackElf = ({ buf, info, newRawBytes, newOffsetsStruct }: ElfRepackInputs): Buffer => { + if (newOffsetsStruct.length !== OFFSETS_SIZE) { + throw new Error(`ELF repack: offsets struct must be ${OFFSETS_SIZE} bytes, got ${newOffsetsStruct.length}`); + } + const delta = newRawBytes.length - info.byteCount; + const tailStart = info.trailerOffset + TRAILER.length; + + // Same-size: no header fields need patching, splice in place via subarrays. + if (delta === 0) { + return Buffer.concat([ + buf.subarray(0, info.dataStart), + newRawBytes, + newOffsetsStruct, + TRAILER, + buf.subarray(tailStart), + ]); + } + + // Writable copy of the prefix so we can patch e_shoff / e_phoff in the + // ELF header (both sit past the data region in real CC binaries) plus + // the inner u64 size header at the .bun section payload start. + const prefix = Buffer.from(buf.subarray(0, info.dataStart)); + if (prefix.length >= ELF_E_SHOFF + 8) { + shiftU64IfPast(prefix, ELF_E_SHOFF, info.dataStart, delta); + shiftU64IfPast(prefix, ELF_E_PHOFF, info.dataStart, delta); + const sectionPayloadStart = info.dataStart - MACHO_SECTION_HEADER_SIZE; + growPtLoadCoveringSection(prefix, sectionPayloadStart, delta); + } + + // Writable copy of the ELF tail so we can patch the .bun section header's + // sh_size. The tail is everything after the original trailer; the section + // header table lives somewhere inside it. + const elfTail = tailStart < buf.length ? Buffer.from(buf.subarray(tailStart)) : Buffer.alloc(0); + + if (elfTail.length > 0) { + const table = readSectionHeaderTable(prefix); + if (table) { + const bunHeaderOff = findBunSectionHeaderOffset(buf, table); + if (bunHeaderOff !== null && bunHeaderOff >= tailStart) { + const offsetWithinTail = bunHeaderOff - tailStart; + if (offsetWithinTail + ELF_SH_SIZE + 8 <= elfTail.length) { + const oldSize = elfTail.readBigUInt64LE(offsetWithinTail + ELF_SH_SIZE); + elfTail.writeBigUInt64LE(oldSize + BigInt(delta), offsetWithinTail + ELF_SH_SIZE); + } + } + } + + // Bun's inner u64 size header at the start of the .bun section payload. + // Records rawBytes length only (no offsets/trailer); the parser exposes + // dataStart already adjusted past it. + const sectionPayloadStart = info.dataStart - MACHO_SECTION_HEADER_SIZE; + if (sectionPayloadStart >= 0 && sectionPayloadStart + 8 <= prefix.length) { + const oldInner = prefix.readBigUInt64LE(sectionPayloadStart); + prefix.writeBigUInt64LE(oldInner + BigInt(delta), sectionPayloadStart); + } + } + + return Buffer.concat([prefix, newRawBytes, newOffsetsStruct, TRAILER, elfTail]); +}; diff --git a/src/core/binary-patcher/index.ts b/src/core/binary-patcher/index.ts new file mode 100644 index 0000000..72cb555 --- /dev/null +++ b/src/core/binary-patcher/index.ts @@ -0,0 +1,173 @@ +/** + * In-repo binary patcher orchestrator. + * + * Reads a Bun-compiled Claude Code binary, runs theme + prompt patches over + * the entry-module JS, repacks (with resize support), writes the result + * back, and (on macOS) re-signs ad-hoc. + * + * Failure modes are returned as structured results, NOT thrown - the caller + * (BinaryPatcherStep) inspects PatchResult.ok and triggers Phase 1 rollback + * (restorePristineBinary + theme reset + meta.tweakRolledBack=true) when + * ok is false. + */ + +import fs from 'node:fs'; + +import type { TweakccConfig } from '../../brands/types.js'; +import type { OverlayMap } from '../prompt-pack/types.js'; + +import { parseBunBinary, replaceModule } from '../bun-extract.js'; +import { ThemeAnchorNotFound, applyTheme } from './theme.js'; +import { applyPrompts } from './prompts.js'; +import { replaceEntryJs } from './replace-entry.js'; +import { tryAdhocSign } from './codesign.js'; +import { PeNotLastSectionError } from './pe-resize.js'; + +export interface PatchInputs { + binaryPath: string; + config: TweakccConfig; + overlays?: OverlayMap | null; +} + +export type PatchFailureReason = 'anchor-not-found' | 'resize-bound-exceeded' | 'io-error'; + +export type PatchResult = + | { + ok: true; + bytesChanged: number; + resigned: boolean; + missingPromptKeys: string[]; + /** When true on macOS, the binary is unsigned because codesign wasn't available. */ + codesignSkipped: boolean; + /** + * Set when the patch was deliberately skipped without modifying the + * binary. Currently only fires on Mach-O when the patched JS would grow + * past the original entry-module size (Mach-O segment shifting is not + * implemented; see CLAUDE.local.md Phase 2 follow-ups). + */ + skippedReason?: 'macho-grow-not-supported'; + } + | { + ok: false; + reason: PatchFailureReason; + detail: string; + }; + +export const applyPatches = ({ binaryPath, config, overlays }: PatchInputs): PatchResult => { + let buf: Buffer; + try { + buf = fs.readFileSync(binaryPath); + } catch (err) { + return { ok: false, reason: 'io-error', detail: `read ${binaryPath}: ${(err as Error).message}` }; + } + + const info = parseBunBinary(buf); + const entry = info.modules[info.entryPointId]; + if (!entry) { + return { ok: false, reason: 'io-error', detail: `entry module id ${info.entryPointId} out of range` }; + } + const oldEntryLen = entry.contLen; + const oldJs = buf + .subarray(info.dataStart + entry.contOff, info.dataStart + entry.contOff + oldEntryLen) + .toString('utf8'); + + // Theme patch first - anchor failure here aborts the whole patch (we don't + // ship a theme-less variant; rollback will restore pristine). + let newJs = oldJs; + try { + const themed = applyTheme(newJs, config.settings.themes); + newJs = themed.js; + } catch (err) { + if (err instanceof ThemeAnchorNotFound) { + return { ok: false, reason: 'anchor-not-found', detail: err.message }; + } + return { ok: false, reason: 'io-error', detail: `applyTheme: ${(err as Error).message}` }; + } + + // Prompt overlays are best-effort - keys whose anchor is missing are + // recorded but don't abort. This matches today's silent no-op behaviour + // for prompt keys whose extracted .md file was absent. + let missingPromptKeys: string[] = []; + if (overlays) { + const promptResult = applyPrompts(newJs, overlays); + newJs = promptResult.js; + missingPromptKeys = promptResult.missing; + } + + // Mach-O resize is intentionally restricted: changing __BUN's size shifts + // every downstream segment (__DATA, __LINKEDIT, codesig blob), and that + // bookkeeping is out of scope for this patcher. Workaround: keep the entry + // module the SAME size by padding shrinks with trailing whitespace, and + // SKIP the patch (binary stays pristine + functional, no theme) when we'd + // grow. The skip is reported via skippedReason so the build step can + // surface a note instead of triggering a rollback. ELF and PE resize fine. + let bytesChanged = 0; + let writeBuf: Buffer | null = null; + let skippedReason: 'macho-grow-not-supported' | undefined; + if (info.platform === 'macho') { + const delta = newJs.length - oldEntryLen; + if (delta > 0) { + skippedReason = 'macho-grow-not-supported'; + } else { + if (delta < 0) { + newJs = newJs + ' '.repeat(-delta); + } + try { + const sized = replaceModule(buf, info, entry.name, Buffer.from(newJs, 'utf8')); + writeBuf = sized.buf; + } catch (err) { + return { ok: false, reason: 'io-error', detail: `replaceModule: ${(err as Error).message}` }; + } + } + } else { + try { + const result = replaceEntryJs(buf, info, Buffer.from(newJs, 'utf8')); + writeBuf = result.buf; + bytesChanged = result.delta; + } catch (err) { + if (err instanceof PeNotLastSectionError) { + return { ok: false, reason: 'resize-bound-exceeded', detail: err.message }; + } + return { ok: false, reason: 'io-error', detail: `replaceEntryJs: ${(err as Error).message}` }; + } + } + + let resigned = false; + let codesignSkipped = false; + + if (writeBuf) { + try { + fs.writeFileSync(binaryPath, writeBuf); + if (process.platform !== 'win32') { + fs.chmodSync(binaryPath, 0o755); + } + } catch (err) { + return { ok: false, reason: 'io-error', detail: `write ${binaryPath}: ${(err as Error).message}` }; + } + + // Re-sign on macOS whenever the original had a signature - any byte change + // invalidates Apple's hash, so codesign --force --sign - replaces it with + // an ad-hoc one. AMFI on Apple Silicon kills binaries whose signature + // doesn't verify, so re-signing is mandatory for the patched binary to + // launch. Both failure modes (no codesign in PATH, or codesign rejected) + // downgrade to a soft codesignSkipped warning; smokeTestBinary in the + // build step catches anything that genuinely can't launch. + if (info.platform === 'macho' && info.hasCodeSignature) { + const sign = tryAdhocSign(binaryPath); + if (sign.signed) { + resigned = true; + } else { + codesignSkipped = true; + } + } + } + + return { + ok: true, + bytesChanged, + resigned, + missingPromptKeys, + codesignSkipped, + skippedReason, + }; +}; diff --git a/src/core/binary-patcher/js-patch.ts b/src/core/binary-patcher/js-patch.ts new file mode 100644 index 0000000..655ae7e --- /dev/null +++ b/src/core/binary-patcher/js-patch.ts @@ -0,0 +1,103 @@ +/** + * JS-side patcher for the unpack-and-run-via-node macOS path. + * + * Replaces the binary-level theme + prompt patching that runs against the + * Bun-compiled cli.js inside the binary's __BUN section. Reads the unpacked + * entry module, strips Bun's CJS wrapper so Node's loader can drive the file, + * applies the same applyTheme + applyPrompts anchors used by the binary + * patcher (they already operate on JS strings), and writes the patched body + * back. Failures throw structured errors so the caller can map them to the + * Phase 1 rollback path. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import type { TweakccConfig } from '../../brands/types.js'; +import type { OverlayKey, OverlayMap } from '../prompt-pack/types.js'; + +import { applyPrompts } from './prompts.js'; +import { applyTheme } from './theme.js'; +import { stripBunWrapper } from './strip-bun-wrapper.js'; + +interface ManifestModule { + name: string; + isEntry?: boolean; +} + +interface UnpackedManifest { + entryPoint?: string; + modules?: ManifestModule[]; +} + +export class UnpackedManifestError extends Error { + constructor(message: string) { + super(`unpacked manifest: ${message}`); + this.name = 'UnpackedManifestError'; + } +} + +export interface PatchUnpackedResult { + /** Absolute path of the entry module that was patched. */ + entryPath: string; + /** Number of theme anchors successfully rewritten (3 on success, 0 if no themes). */ + themeReplaced: number; + /** OverlayKeys for which a prompt anchor was applied. */ + promptReplaced: OverlayKey[]; + /** OverlayKeys for which an anchor was missing (silent no-op upstream). */ + promptMissing: OverlayKey[]; +} + +export const resolveEntryPath = (unpackedDir: string): string => { + const manifestPath = path.join(unpackedDir, 'manifest.json'); + let manifest: UnpackedManifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as UnpackedManifest; + } catch (err) { + throw new UnpackedManifestError(`read ${manifestPath}: ${(err as Error).message}`); + } + const entryName = manifest.entryPoint ?? manifest.modules?.find((m) => m.isEntry)?.name; + if (!entryName) throw new UnpackedManifestError('no entry module in manifest'); + return path.join(unpackedDir, entryName); +}; + +/** + * Read entry, strip wrapper, apply theme + prompts, write back. The file is + * left in a Node-CJS-compatible shape (no Bun wrapper). Re-running this on + * an already-patched directory is safe: stripBunWrapper round-trips on + * already-stripped input, and applyPrompts replaces existing overlay blocks + * rather than duplicating them. + */ +export const patchUnpackedEntry = ({ + unpackedDir, + config, + overlays, +}: { + unpackedDir: string; + config: TweakccConfig; + overlays: OverlayMap | null; +}): PatchUnpackedResult => { + const entryPath = resolveEntryPath(unpackedDir); + + // Read as latin1 so any non-UTF-8 byte values inside the bundled JS round-trip + // safely. The bundler treats the entry as 8-bit clean (encoding: latin1 in + // the manifest). + const raw = fs.readFileSync(entryPath, 'latin1'); + const body = stripBunWrapper(raw); + + const themed = applyTheme(body, config.settings.themes); + let js = themed.js; + + let promptReplaced: OverlayKey[] = []; + let promptMissing: OverlayKey[] = []; + if (overlays) { + const result = applyPrompts(js, overlays); + js = result.js; + promptReplaced = result.replacedTargets; + promptMissing = result.missing; + } + + fs.writeFileSync(entryPath, js, 'latin1'); + + return { entryPath, themeReplaced: themed.replaced, promptReplaced, promptMissing }; +}; diff --git a/src/core/binary-patcher/macho-resize.ts b/src/core/binary-patcher/macho-resize.ts new file mode 100644 index 0000000..50a7d86 --- /dev/null +++ b/src/core/binary-patcher/macho-resize.ts @@ -0,0 +1,260 @@ +/** + * Mach-O resize: rewrite the __BUN section_64 size field, rewrite the 8-byte + * u64 size header at the section start, and (when present) strip the + * LC_CODE_SIGNATURE load command. Re-signing is left to codesign.ts. + * + * We deliberately re-walk the load command table here instead of reusing + * bun-extract/macho.ts. The read-only path uses heuristic scans tuned for + * speed; the write path needs precise offsets and section-header location. + */ + +import { + MACHO_HEADER_SCAN_BYTES, + MACHO_MAGIC_64, + MACHO_MAGIC_64_BE, + MACHO_SECTION_HEADER_SIZE, + OFFSETS_SIZE, + TRAILER, +} from '../bun-extract/constants.js'; +import type { BunBinaryInfo } from '../bun-extract.js'; + +const LC_CODE_SIGNATURE = 0x1d; +const LC_SEGMENT_64 = 0x19; +const MACH_HEADER_64_SIZE = 32; +const LINKEDIT_SEGNAME = '__LINKEDIT'; + +export interface MachoRepackInputs { + buf: Buffer; + info: BunBinaryInfo; + newRawBytes: Buffer; + newOffsetsStruct: Buffer; +} + +export interface MachoRepackResult { + buf: Buffer; + signatureStripped: boolean; +} + +interface SectionHeaderLocation { + /** File offset of the section_64 struct itself. */ + headerOffset: number; +} + +interface CodeSigLocation { + /** Offset of the LC_CODE_SIGNATURE load command within the buffer. */ + lcOffset: number; + /** cmdsize field on the load command (always 16 for LC_CODE_SIGNATURE). */ + cmdsize: number; + /** dataoff: file offset where the signature blob starts. */ + dataoff: number; + /** datasize: byte length of the signature blob. */ + datasize: number; +} + +interface LinkeditSegment { + /** Offset of the LC_SEGMENT_64 load command within the buffer. */ + lcOffset: number; + /** Current filesize (file bytes claimed by this segment). */ + filesize: bigint; + /** Current vmsize (virtual memory bytes claimed by this segment). */ + vmsize: bigint; +} + +const isMacho64 = (buf: Buffer): boolean => { + if (buf.length < 4) return false; + const magic = buf.readUInt32LE(0); + return magic === MACHO_MAGIC_64 || magic === MACHO_MAGIC_64_BE; +}; + +/** Locate the section_64 struct for __BUN; returns its file offset (start of sectname). */ +const findBunSectionHeader = (buf: Buffer): SectionHeaderLocation | null => { + const limit = Math.min(buf.length, MACHO_HEADER_SCAN_BYTES); + for (let i = 0; i < limit - 56; i += 1) { + if ( + buf[i] === 0x5f && + buf[i + 1] === 0x5f && + buf[i + 2] === 0x62 && + buf[i + 3] === 0x75 && + buf[i + 4] === 0x6e && + buf[i + 5] === 0x00 && + buf[i + 16] === 0x5f && + buf[i + 17] === 0x5f && + buf[i + 18] === 0x42 && + buf[i + 19] === 0x55 && + buf[i + 20] === 0x4e + ) { + return { headerOffset: i }; + } + } + return null; +}; + +/** + * Walk the mach_header_64 load-command table to find LC_CODE_SIGNATURE precisely. + * + * mach_header_64: magic(4) cputype(4) cpusubtype(4) filetype(4) ncmds(4) + * sizeofcmds(4) flags(4) reserved(4) = 32 bytes. + * Each load command starts with cmd(u32) cmdsize(u32). LC_CODE_SIGNATURE has + * cmdsize=16 with dataoff(u32) datasize(u32) trailing. + */ +const findCodeSignatureLc = (buf: Buffer): CodeSigLocation | null => { + if (!isMacho64(buf)) return null; + if (buf.length < MACH_HEADER_64_SIZE) return null; + const ncmds = buf.readUInt32LE(16); + const sizeofcmds = buf.readUInt32LE(20); + if (sizeofcmds === 0 || ncmds === 0) return null; + + let cursor = MACH_HEADER_64_SIZE; + const end = MACH_HEADER_64_SIZE + sizeofcmds; + if (end > buf.length) return null; + + for (let i = 0; i < ncmds; i += 1) { + if (cursor + 8 > end) return null; + const cmd = buf.readUInt32LE(cursor); + const cmdsize = buf.readUInt32LE(cursor + 4); + if (cmdsize < 8 || cursor + cmdsize > end) return null; + if (cmd === LC_CODE_SIGNATURE && cmdsize === 16) { + const dataoff = buf.readUInt32LE(cursor + 8); + const datasize = buf.readUInt32LE(cursor + 12); + return { lcOffset: cursor, cmdsize, dataoff, datasize }; + } + cursor += cmdsize; + } + return null; +}; + +/** + * Find the LC_SEGMENT_64 load command for __LINKEDIT, where the code signature + * blob lives. Returning the lcOffset + filesize/vmsize lets stripCodeSignature + * shrink the segment by the signature size so codesign can re-sign cleanly. + * + * LC_SEGMENT_64 layout: cmd(4) cmdsize(4) segname[16] vmaddr(8) vmsize(8) + * fileoff(8) filesize(8) maxprot(4) initprot(4) + * nsects(4) flags(4) = 72 bytes header + */ +const findLinkeditSegment = (buf: Buffer): LinkeditSegment | null => { + if (buf.length < MACH_HEADER_64_SIZE) return null; + const ncmds = buf.readUInt32LE(16); + const sizeofcmds = buf.readUInt32LE(20); + if (sizeofcmds === 0 || ncmds === 0) return null; + + let cursor = MACH_HEADER_64_SIZE; + const end = MACH_HEADER_64_SIZE + sizeofcmds; + if (end > buf.length) return null; + + for (let i = 0; i < ncmds; i += 1) { + if (cursor + 8 > end) return null; + const cmd = buf.readUInt32LE(cursor); + const cmdsize = buf.readUInt32LE(cursor + 4); + if (cmdsize < 8 || cursor + cmdsize > end) return null; + if (cmd === LC_SEGMENT_64 && cmdsize >= 72) { + const segname = buf + .subarray(cursor + 8, cursor + 24) + .toString('utf8') + .replace(/\0+$/, ''); + if (segname === LINKEDIT_SEGNAME) { + return { + lcOffset: cursor, + vmsize: buf.readBigUInt64LE(cursor + 32), + filesize: buf.readBigUInt64LE(cursor + 48), + }; + } + } + cursor += cmdsize; + } + return null; +}; + +/** + * Strip the LC_CODE_SIGNATURE load command in place. + * + * Strategy: shift any subsequent load commands left by `cmdsize` bytes, + * zero out the freed tail, decrement ncmds and sizeofcmds. We do not touch + * the actual signature blob in the file; the trailing region gets dropped + * naturally by the suffix replacement when the buffer is reassembled (the + * old [rawBytes][offsets][trailer][codesig padding] suffix is replaced with + * [newRaw][newOffsets][trailer]). + */ +const stripCodeSignature = (header: Buffer, lc: CodeSigLocation): void => { + const ncmds = header.readUInt32LE(16); + const sizeofcmds = header.readUInt32LE(20); + const lcEnd = MACH_HEADER_64_SIZE + sizeofcmds; + const tailStart = lc.lcOffset + lc.cmdsize; + const tailLen = lcEnd - tailStart; + + if (tailLen > 0) { + header.copyWithin(lc.lcOffset, tailStart, lcEnd); + } + // Zero out the freed cmdsize bytes at the end of the LC region. + header.fill(0, lcEnd - lc.cmdsize, lcEnd); + header.writeUInt32LE(ncmds - 1, 16); + header.writeUInt32LE(sizeofcmds - lc.cmdsize, 20); +}; + +/** + * Rewrite the section_64 size field for __BUN. + * + * section_64 layout: sectname[16] segname[16] addr(u64) size(u64) offset(u32) + * align(u32) reloff(u32) nreloc(u32) flags(u32) ... + * The size field sits at sectname_offset + 40. + */ +const rewriteSectionSize = (header: Buffer, headerOffset: number, newSize: number): void => { + header.writeBigUInt64LE(BigInt(newSize), headerOffset + 40); +}; + +export const repackMacho = ({ buf, info, newRawBytes, newOffsetsStruct }: MachoRepackInputs): MachoRepackResult => { + if (newOffsetsStruct.length !== OFFSETS_SIZE) { + throw new Error(`Mach-O repack: offsets struct must be ${OFFSETS_SIZE} bytes, got ${newOffsetsStruct.length}`); + } + if (info.sectionOffset === undefined) { + throw new Error( + 'Mach-O repack: BunBinaryInfo missing sectionOffset (read-only path could not locate __BUN section)' + ); + } + + const sectionHeader = findBunSectionHeader(buf); + if (!sectionHeader) { + throw new Error('Mach-O repack: could not relocate __BUN section_64 struct for header rewrite'); + } + + // Pre-section bytes: everything from file start up to (and excluding) the section payload. + // Layout: [mach_header + load_commands ...][section_64 payload at info.sectionOffset] + const preSection = Buffer.from(buf.subarray(0, info.sectionOffset)); + + // Strip LC_CODE_SIGNATURE if present. The signature blob sits at the tail of + // __LINKEDIT, so we must also reduce that segment's filesize/vmsize by the + // signature size - otherwise codesign rejects the patched binary with + // "main executable failed strict validation" (LINKEDIT extends past EOF). + let signatureStripped = false; + const codeSig = findCodeSignatureLc(preSection); + if (codeSig) { + const linkedit = findLinkeditSegment(preSection); + if (linkedit) { + const sigSize = BigInt(codeSig.datasize); + const newFilesize = linkedit.filesize > sigSize ? linkedit.filesize - sigSize : 0n; + const newVmsize = linkedit.vmsize > sigSize ? linkedit.vmsize - sigSize : 0n; + preSection.writeBigUInt64LE(newFilesize, linkedit.lcOffset + 48); + preSection.writeBigUInt64LE(newVmsize, linkedit.lcOffset + 32); + } + stripCodeSignature(preSection, codeSig); + signatureStripped = true; + } + + // New section payload size: 8-byte u64 size header + rawBytes + offsets + trailer. + const newSectionInnerSize = newRawBytes.length + OFFSETS_SIZE + TRAILER.length; + const newSectionPayloadSize = MACHO_SECTION_HEADER_SIZE + newSectionInnerSize; + + // Rewrite section_64.size to the new payload size (Apple counts the entire + // section content including the 8-byte u64 size prefix Bun emits). + rewriteSectionSize(preSection, sectionHeader.headerOffset, newSectionPayloadSize); + + // 8-byte u64 LE size header at section start = inner rawBytes length only + // (not including offsets/trailer), per Bun's emitter. + const sectionSizeHeader = Buffer.alloc(MACHO_SECTION_HEADER_SIZE); + sectionSizeHeader.writeBigUInt64LE(BigInt(newRawBytes.length), 0); + + // Drop any bytes after the original [rawBytes][offsets][trailer] suffix + // (signature padding etc. — re-signing happens later via codesign). + const out = Buffer.concat([preSection, sectionSizeHeader, newRawBytes, newOffsetsStruct, TRAILER]); + return { buf: out, signatureStripped }; +}; diff --git a/src/core/binary-patcher/pe-resize.ts b/src/core/binary-patcher/pe-resize.ts new file mode 100644 index 0000000..3138f22 --- /dev/null +++ b/src/core/binary-patcher/pe-resize.ts @@ -0,0 +1,103 @@ +/** + * PE resize: rewrite the .bun section's SizeOfRawData and Misc.VirtualSize. + * + * We require .bun to be the last raw-data section in the file. The guard + * walks the section table and asserts no section's PointerToRawData lies + * past .bun's. If that ever fails (a future Bun release inserts something + * downstream), the patcher returns a structured error so the build pipeline + * triggers Phase 1 rollback rather than corrupting downstream sections. + */ + +import { OFFSETS_SIZE, PE_DOS_MAGIC, PE_NT_SIGNATURE, TRAILER } from '../bun-extract/constants.js'; +import type { BunBinaryInfo } from '../bun-extract.js'; + +const SECTION_HEADER_SIZE = 40; +const NAME_BYTES = [0x2e, 0x62, 0x75, 0x6e, 0x00]; // ".bun\0" + +export interface PeRepackInputs { + buf: Buffer; + info: BunBinaryInfo; + newRawBytes: Buffer; + newOffsetsStruct: Buffer; +} + +export class PeNotLastSectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'PeNotLastSectionError'; + } +} + +interface PeLayout { + bunSectionHeaderOff: number; + bunPointerToRawData: number; + bunSizeOfRawData: number; +} + +const findPeLayout = (buf: Buffer): PeLayout | null => { + if (buf.length < 0x40) return null; + if (buf.readUInt16LE(0) !== PE_DOS_MAGIC) return null; + const peOff = buf.readUInt32LE(0x3c); + if (peOff <= 0 || peOff + 24 > buf.length) return null; + if (buf.readUInt32LE(peOff) !== PE_NT_SIGNATURE) return null; + + const numSections = buf.readUInt16LE(peOff + 6); + const sizeOfOptional = buf.readUInt16LE(peOff + 20); + const sectionsStart = peOff + 24 + sizeOfOptional; + + let bun: PeLayout | null = null; + let highestPtr = -1; + let highestSection = ''; + + for (let i = 0; i < numSections; i += 1) { + const base = sectionsStart + i * SECTION_HEADER_SIZE; + if (base + SECTION_HEADER_SIZE > buf.length) return null; + const ptr = buf.readUInt32LE(base + 20); + const size = buf.readUInt32LE(base + 16); + const isBun = NAME_BYTES.every((b, j) => buf[base + j] === b); + if (isBun) { + bun = { bunSectionHeaderOff: base, bunPointerToRawData: ptr, bunSizeOfRawData: size }; + } + if (ptr > highestPtr) { + highestPtr = ptr; + highestSection = buf + .subarray(base, base + 8) + .toString('utf8') + .replace(/\0+$/, ''); + } + } + + if (!bun) return null; + if (bun.bunPointerToRawData !== highestPtr) { + throw new PeNotLastSectionError( + `.bun is not the last raw-data section (highest is "${highestSection}" at ${highestPtr}; .bun is at ${bun.bunPointerToRawData})` + ); + } + return bun; +}; + +export const repackPe = ({ buf, info, newRawBytes, newOffsetsStruct }: PeRepackInputs): Buffer => { + if (newOffsetsStruct.length !== OFFSETS_SIZE) { + throw new Error(`PE repack: offsets struct must be ${OFFSETS_SIZE} bytes, got ${newOffsetsStruct.length}`); + } + if (info.sectionOffset === undefined) { + throw new Error('PE repack: BunBinaryInfo missing sectionOffset (read-only path could not locate .bun section)'); + } + + const layout = findPeLayout(buf); + if (!layout) { + throw new Error('PE repack: could not locate .bun section header'); + } + + const newSectionSize = newRawBytes.length + OFFSETS_SIZE + TRAILER.length; + + // Pre-section bytes: everything up to .bun's PointerToRawData. + // Section headers are part of this prefix; we patch the .bun section header in-place. + const prefix = Buffer.from(buf.subarray(0, layout.bunPointerToRawData)); + + // SizeOfRawData at base+16, Misc.VirtualSize at base+8. + prefix.writeUInt32LE(newSectionSize, layout.bunSectionHeaderOff + 16); + prefix.writeUInt32LE(newSectionSize, layout.bunSectionHeaderOff + 8); + + return Buffer.concat([prefix, newRawBytes, newOffsetsStruct, TRAILER]); +}; diff --git a/src/core/binary-patcher/prompts.ts b/src/core/binary-patcher/prompts.ts new file mode 100644 index 0000000..4b5ea40 --- /dev/null +++ b/src/core/binary-patcher/prompts.ts @@ -0,0 +1,152 @@ +/** + * Prompt overlay patcher for the bundled Claude Code cli.js. + * + * Replaces cc-mirror's previous tweakDir/system-prompts/.md file roundtrip + * with a direct splice into the JS source. For each OverlayKey we know how to + * patch, we anchor on a unique tail substring of the prompt's last literal + * piece (sourced from tweakcc's repos/tweakcc/data/prompts/prompts-.json + * catalog and adapted under MIT). The overlay block lives BETWEEN the tail + * anchor and the next chunk of prompt text - which is the closing string + * delimiter for prompts whose last piece runs to end-of-string. + * + * Re-application semantics match the old applyPromptPack: if our markers are + * already present in the JS, replace the existing block; otherwise insert a + * fresh block. + * + * Anchor coverage starts at the eight overlay keys that today's prompt-pack + * actually patches successfully (verified against ~/.cc-mirror's extracted + * system-prompts/ directory). The other OverlayKeys silently no-op, matching + * pre-Phase-2 behaviour. + */ + +import type { OverlayKey, OverlayMap } from '../prompt-pack/types.js'; + +export const OVERLAY_MARKERS = { + start: '', + end: '', +}; + +interface AnchorSpec { + /** + * A literal substring near the end of the prompt's last piece. Must be unique + * in the bundled JS and stable across minor Claude Code updates. Sourced from + * repos/tweakcc/data/prompts/prompts-.json (.prompts[].pieces[-1] tail). + */ + tail: string; +} + +const ANCHORS: Partial> = { + webfetch: { + tail: '- For GitHub URLs, prefer using the gh CLI via Bash instead (e.g., gh pr view, gh issue view, gh api).', + }, + websearch: { + tail: 'Example: If the user asks for "latest React docs", search for "React documentation" with the current year, NOT last year', + }, + explore: { + tail: "Complete the user's search request efficiently and report your findings clearly.", + }, + planEnhanced: { + tail: 'REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.', + }, + enterPlan: { + tail: '- Users appreciate being consulted before significant changes are made to their codebase', + }, + skill: { + tail: 'tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling this tool again', + }, + conversationSummary: { + tail: 'When you are using compact - please focus on test output and code changes. Include file reads verbatim.', + }, + webfetchSummary: { + tail: '- Never produce or reproduce exact song lyrics.', + }, +}; + +export interface ApplyPromptsResult { + js: string; + replacedTargets: OverlayKey[]; + missing: OverlayKey[]; +} + +/** + * Detect the string delimiter that wraps the prompt at `index`. Walk backwards + * a few KB looking for the most recent `\``, `"`, or `'`. Bun-compiled + * Claude Code uses template literals for prompts that interpolate identifiers, + * but a fallback to plain quotes keeps us compatible if upstream simplifies + * a prompt later. + */ +const detectDelimiter = (js: string, index: number): '`' | '"' | "'" => { + const start = Math.max(0, index - 8192); + for (let i = index - 1; i >= start; i -= 1) { + const c = js[i]; + if (c === '`' || c === '"' || c === "'") return c; + } + return '`'; +}; + +const escapeForDelimiter = (text: string, delim: '`' | '"' | "'"): string => { + if (delim === '`') { + // Template literal: escape backticks and ${ interpolations. + return text.replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); + } + // Single/double quote: escape the delimiter and turn newlines into \n escapes. + const escaped = text.replace(new RegExp(`(? { + const trimmed = overlay.trim(); + if (!trimmed) return ''; + const block = `\n\n${OVERLAY_MARKERS.start}\n${trimmed}\n${OVERLAY_MARKERS.end}\n`; + return escapeForDelimiter(block, delim); +}; + +/** + * Locate (and remove) any existing overlay block right after `tailEnd`. Returns + * the offset where the next byte of original prompt content resumes. Same idea + * as the existing prompt-pack.ts upsert: replace, don't duplicate. + */ +const stripExistingBlock = (js: string, tailEnd: number, delim: '`' | '"' | "'"): { js: string; tailEnd: number } => { + const escapedStart = escapeForDelimiter(`\n\n${OVERLAY_MARKERS.start}`, delim); + const escapedEnd = escapeForDelimiter(`${OVERLAY_MARKERS.end}\n`, delim); + if (js.slice(tailEnd, tailEnd + escapedStart.length) !== escapedStart) return { js, tailEnd }; + const endIdx = js.indexOf(escapedEnd, tailEnd + escapedStart.length); + if (endIdx === -1) return { js, tailEnd }; + const stripUntil = endIdx + escapedEnd.length; + return { js: js.slice(0, tailEnd) + js.slice(stripUntil), tailEnd }; +}; + +export const applyPrompts = (oldFile: string, overlays: OverlayMap): ApplyPromptsResult => { + const replacedTargets: OverlayKey[] = []; + const missing: OverlayKey[] = []; + let js = oldFile; + + for (const [keyRaw, overlayText] of Object.entries(overlays)) { + const key = keyRaw as OverlayKey; + if (!overlayText || !overlayText.trim()) continue; + + const spec = ANCHORS[key]; + if (!spec) { + missing.push(key); + continue; + } + + const tailIdx = js.indexOf(spec.tail); + if (tailIdx === -1) { + missing.push(key); + continue; + } + const tailEnd = tailIdx + spec.tail.length; + const delim = detectDelimiter(js, tailIdx); + + const stripped = stripExistingBlock(js, tailEnd, delim); + js = stripped.js; + const block = buildOverlayBlock(overlayText, delim); + if (!block) continue; + + js = js.slice(0, stripped.tailEnd) + block + js.slice(stripped.tailEnd); + replacedTargets.push(key); + } + + return { js, replacedTargets, missing }; +}; diff --git a/src/core/binary-patcher/repack.ts b/src/core/binary-patcher/repack.ts new file mode 100644 index 0000000..e3cda15 --- /dev/null +++ b/src/core/binary-patcher/repack.ts @@ -0,0 +1,57 @@ +/** + * Cross-platform Bun standalone-binary repack with resize support. + * + * Inputs: the original buffer + parsed BunBinaryInfo + a newly-built rawBytes + * region (data slabs followed by the module table) and a freshly-built + * Offsets struct. Output: a buffer that boots correctly on the target + * platform with the new payload. + * + * Caller responsibilities: + * - Build newRawBytes with all StringPointer offsets already adjusted for + * the resize delta. repack does NOT rewrite the module table. + * - Build newOffsetsStruct with the new byteCount and (possibly shifted) + * modulesOff. repack does NOT rewrite the offsets struct. + * + * What repack owns: the platform container plumbing (Mach-O section_64 size + * field + 8-byte size header + LC_CODE_SIGNATURE strip; PE .bun section + * header SizeOfRawData/VirtualSize + last-section guard; ELF appended-region + * truncation). + */ + +import { repackElf } from './elf-resize.js'; +import { repackMacho } from './macho-resize.js'; +import { repackPe } from './pe-resize.js'; +import type { BunBinaryInfo } from '../bun-extract.js'; + +export interface RepackInputs { + buf: Buffer; + info: BunBinaryInfo; + newRawBytes: Buffer; + newOffsetsStruct: Buffer; +} + +export interface RepackResult { + buf: Buffer; + signatureStripped: boolean; +} + +export const repackBinary = ({ buf, info, newRawBytes, newOffsetsStruct }: RepackInputs): RepackResult => { + switch (info.platform) { + case 'elf': + return { + buf: repackElf({ buf, info, newRawBytes, newOffsetsStruct }), + signatureStripped: false, + }; + case 'macho': + return repackMacho({ buf, info, newRawBytes, newOffsetsStruct }); + case 'pe': + return { + buf: repackPe({ buf, info, newRawBytes, newOffsetsStruct }), + signatureStripped: false, + }; + default: { + const _exhaustive: never = info.platform; + throw new Error(`repackBinary: unhandled platform ${_exhaustive as string}`); + } + } +}; diff --git a/src/core/binary-patcher/replace-entry.ts b/src/core/binary-patcher/replace-entry.ts new file mode 100644 index 0000000..dbf4ad4 --- /dev/null +++ b/src/core/binary-patcher/replace-entry.ts @@ -0,0 +1,92 @@ +/** + * Resize-capable replacement of the Bun entry-point module. + * + * Companion to bun-extract's same-size replaceModule. Used by the patcher + * pipeline to swap in a modified cli.js whose length differs from the + * original. Walks the module table once, shifts every StringPointer past + * the entry's content end by the resize delta, rebuilds the Offsets struct, + * then hands off to repackBinary for the platform container rewrite. + * + * Why entry-only: theme + prompt patches all live in the bundled cli.js, + * which is the entry module. Generalising to arbitrary modules would force + * us to re-sort the data slabs (each module's name/sourcemap/bytecode lives + * adjacent to its content; resizing a non-entry module would shift slabs in + * a way that the simple ">= cut" rule below cannot express). + */ + +import { OFFSETS_SIZE } from '../bun-extract/constants.js'; +import { BunFormatError, type BunBinaryInfo } from '../bun-extract.js'; +import { repackBinary } from './repack.js'; + +export interface ReplaceEntryResult { + buf: Buffer; + signatureInvalidated: boolean; + signatureStripped: boolean; + delta: number; +} + +export const replaceEntryJs = (buf: Buffer, info: BunBinaryInfo, newContent: Buffer): ReplaceEntryResult => { + const entry = info.modules[info.entryPointId]; + if (!entry) { + throw new BunFormatError(`Entry module id ${info.entryPointId} out of range (have ${info.modules.length} modules)`); + } + + const oldEntryLen = entry.contLen; + const newEntryLen = newContent.length; + const delta = newEntryLen - oldEntryLen; + const cut = entry.contOff + oldEntryLen; + + // Resolve old module-table position from the original Offsets struct. + const offsetsStart = info.trailerOffset - OFFSETS_SIZE; + const oldModulesOff = buf.readUInt32LE(offsetsStart + 8); + const oldModulesLen = buf.readUInt32LE(offsetsStart + 12); + + // Build new rawBytes: bytes-before-entry + newContent + bytes-after-entry. + const oldRawBytes = buf.subarray(info.dataStart, info.dataStart + info.byteCount); + const newRawBytes = Buffer.concat([ + oldRawBytes.subarray(0, entry.contOff), + newContent, + oldRawBytes.subarray(entry.contOff + oldEntryLen), + ]); + + // Module table offset within newRawBytes: shifts by delta if it sat past the cut. + const newModulesOff = oldModulesOff >= cut ? oldModulesOff + delta : oldModulesOff; + + // Walk the module table and rewrite every StringPointer whose target is + // at-or-past the cut by +=delta. The entry module's own content offset is + // strictly less than cut (cut = entry.contOff + entry.contLen), so it + // doesn't shift; we only update its length field. + const moduleSize = info.moduleSize; + for (let i = 0; i < info.modules.length; i += 1) { + const base = newModulesOff + i * moduleSize; + // Four StringPointers per module: name(0), content(8), sourcemap(16), bytecode(24). + for (const slot of [0, 8, 16, 24]) { + const ptrOff = newRawBytes.readUInt32LE(base + slot); + const ptrLen = newRawBytes.readUInt32LE(base + slot + 4); + if (ptrLen === 0) continue; + if (ptrOff >= cut) { + newRawBytes.writeUInt32LE(ptrOff + delta, base + slot); + } + } + if (i === info.entryPointId) { + newRawBytes.writeUInt32LE(newEntryLen, base + 8 + 4); + } + } + + // Build new Offsets struct. exec_argv (bytes 20..28) is opaque to us; copy through. + const newOffsets = Buffer.alloc(OFFSETS_SIZE); + newOffsets.writeBigUInt64LE(BigInt(newRawBytes.length), 0); + newOffsets.writeUInt32LE(newModulesOff, 8); + newOffsets.writeUInt32LE(oldModulesLen, 12); + newOffsets.writeUInt32LE(info.entryPointId, 16); + buf.copy(newOffsets, 20, offsetsStart + 20, offsetsStart + 28); + newOffsets.writeUInt32LE(info.flags, 28); + + const repacked = repackBinary({ buf, info, newRawBytes, newOffsetsStruct: newOffsets }); + return { + buf: repacked.buf, + signatureInvalidated: info.platform === 'macho' && info.hasCodeSignature, + signatureStripped: repacked.signatureStripped, + delta, + }; +}; diff --git a/src/core/binary-patcher/strip-bun-wrapper.ts b/src/core/binary-patcher/strip-bun-wrapper.ts new file mode 100644 index 0000000..d177f28 --- /dev/null +++ b/src/core/binary-patcher/strip-bun-wrapper.ts @@ -0,0 +1,46 @@ +/** + * Strip Bun's CommonJS wrapper from an extracted entry module. + * + * Bun-compiled standalone binaries embed their entry module as: + * + * // @bun @bytecode @bun-cjs + * (function(exports, require, module, __filename, __dirname) {}); + * + * Bun's runtime invokes that function with the right CJS arguments. Node's + * own CommonJS loader doesn't auto-call function expressions, so on extraction + * the body never runs. Stripping the wrapper turns the file back into a + * regular Node-CJS module: Node wraps it itself with the same five arguments. + * + * We strip in two parts: + * - Leading: `// @bun ...\n(function() {` + * - Trailing: `})` (with optional whitespace/semicolon) + */ + +export class BunWrapperNotFound extends Error { + constructor(public readonly anchor: 'open' | 'close') { + super(`strip-bun-wrapper: ${anchor} anchor not found`); + this.name = 'BunWrapperNotFound'; + } +} + +const WRAPPER_OPEN = /^\/\/ @bun[^\n]*\n\(function\([^)]*\) \{/; + +/** + * Returns the module body with Bun's CJS wrapper removed. Idempotent: a file + * without the wrapper round-trips unchanged. + */ +export const stripBunWrapper = (source: string): string => { + const open = source.match(WRAPPER_OPEN); + if (!open || open.index === undefined) { + if (!source.startsWith('// @bun')) return source; + throw new BunWrapperNotFound('open'); + } + + let end = source.length; + while (end > 0 && /\s|;/.test(source[end - 1])) end -= 1; + if (end < 2 || source.slice(end - 2, end) !== '})') { + throw new BunWrapperNotFound('close'); + } + + return source.slice(open[0].length, end - 2); +}; diff --git a/src/core/binary-patcher/theme.ts b/src/core/binary-patcher/theme.ts new file mode 100644 index 0000000..9bf780f --- /dev/null +++ b/src/core/binary-patcher/theme.ts @@ -0,0 +1,149 @@ +/** + * Theme color patcher for the bundled Claude Code cli.js. + * + * Adapted from tweakcc 303b756 src/patches/themes.ts (MIT, Piebald LLC). + * Three regex anchors locate (a) the theme switch statement, (b) the theme + * options array, and (c) the theme-name mapping object. We rewrite all three + * with our brand themes so the bundled JS knows about them. + * + * Returns a structured failure if any anchor is missing - the caller (Phase 1 + * rollback) restores the pristine binary in that case. + * + * Not idempotent: the objArr/switch regexes only match upstream's pristine + * shape (e.g., labels starting with Dark/Light/Auto/Monochrome). cc-mirror + * always patches from a cached pristine binary, so this is fine. + */ + +import type { Theme } from '../../brands/types.js'; + +export class ThemeAnchorNotFound extends Error { + constructor(public readonly anchor: 'switch' | 'objArr' | 'obj') { + super(`theme: failed to find ${anchor} anchor in cli.js`); + this.name = 'ThemeAnchorNotFound'; + } +} + +interface LocationResult { + startIndex: number; + endIndex: number; + identifiers?: string[]; +} + +interface ThemeLocations { + switchStatement: LocationResult; + objArr: LocationResult; + obj: LocationResult; +} + +const findThemeLocations = (oldFile: string): ThemeLocations | { missing: 'switch' | 'objArr' | 'obj' } => { + // Switch statement: CC >=2.1.83 emits `switch(A){case"light":return LX9;...default:return CX9}`, + // older CC inlines the objects directly. + let switchStart = -1; + let switchEnd = -1; + let switchIdent = ''; + + const newSwitchPat = /switch\(([$\w]+)\)\{case"(?:light|dark)":[^}]*return [$\w]+;[^}]*default:return [$\w]+\}/; + const newSwitchMatch = oldFile.match(newSwitchPat); + + if (newSwitchMatch && newSwitchMatch.index !== undefined) { + switchStart = newSwitchMatch.index; + switchEnd = switchStart + newSwitchMatch[0].length; + switchIdent = newSwitchMatch[1]; + } else { + const darkAnchor = oldFile.indexOf('case"dark":return{'); + const lightAnchor = oldFile.indexOf('case"light":return{'); + const anchor = darkAnchor !== -1 ? darkAnchor : lightAnchor; + if (anchor === -1) return { missing: 'switch' }; + + const before = oldFile.slice(Math.max(0, anchor - 200), anchor); + const switchOpen = before.match(/switch\(([$\w]+)\)\{\s*$/); + if (!switchOpen || switchOpen.index === undefined) return { missing: 'switch' }; + + switchStart = Math.max(0, anchor - 200) + switchOpen.index; + switchIdent = switchOpen[1]; + let depth = 0; + for (let i = switchStart; i < oldFile.length && i < switchStart + 50000; i += 1) { + if (oldFile[i] === '{') depth += 1; + if (oldFile[i] === '}') { + depth -= 1; + if (depth === 0) { + switchEnd = i + 1; + break; + } + } + } + } + + if (switchStart === -1 || switchEnd === -1) return { missing: 'switch' }; + + // Theme options array: [{label:"Dark...",value:"..."},...] — quotes optional. + const objArrPat = /\[(?:\.\.\.\[\],)?(?:\{"?label"?:"(?:Dark|Light|Auto|Monochrome)[^"]*","?value"?:"[^"]+"\},?)+\]/; + const objArrMatch = oldFile.match(objArrPat); + if (!objArrMatch || objArrMatch.index === undefined) return { missing: 'objArr' }; + + // Theme-name mapping: {dark:"Dark mode",...} + const objPat = /(?:return|[$\w]+=)\{(?:"?(?:[$\w-]+)"?:"(?:Auto |Dark|Light|Monochrome)[^"]*",?)+\}/; + const objMatch = oldFile.match(objPat); + if (!objMatch || objMatch.index === undefined) return { missing: 'obj' }; + + return { + switchStatement: { startIndex: switchStart, endIndex: switchEnd, identifiers: [switchIdent] }, + objArr: { startIndex: objArrMatch.index, endIndex: objArrMatch.index + objArrMatch[0].length }, + obj: { startIndex: objMatch.index, endIndex: objMatch.index + objMatch[0].length }, + }; +}; + +export interface ApplyThemeResult { + js: string; + replaced: number; +} + +export const applyTheme = (oldFile: string, themes: Theme[]): ApplyThemeResult => { + if (themes.length === 0) return { js: oldFile, replaced: 0 }; + + const located = findThemeLocations(oldFile); + if ('missing' in located) { + throw new ThemeAnchorNotFound(located.missing); + } + const locations = located; + let newFile = oldFile; + + // Rewrite from highest startIndex to lowest so earlier slice positions stay valid. + // The three anchors don't overlap, so doing them in any order is correct as long + // as each rewrite uses the most recent newFile and slices off its OWN matched + // span. We follow upstream's order (obj → objArr → switch) and re-resolve nothing + // because the prior rewrite is to a region that comes BEFORE the next anchor. + // (We rely on tweakcc's empirical observation that obj < objArr < switch in CC's + // bundled output.) + + const objText = 'return' + JSON.stringify(Object.fromEntries(themes.map((t) => [t.id, t.name]))); + newFile = newFile.slice(0, locations.obj.startIndex) + objText + newFile.slice(locations.obj.endIndex); + + const objArrText = JSON.stringify(themes.map((t) => ({ label: t.name, value: t.id }))); + // Adjust objArr offsets for the obj rewrite delta if obj came first in the file. + const objDelta = objText.length - (locations.obj.endIndex - locations.obj.startIndex); + const objArrStart = + locations.objArr.startIndex >= locations.obj.endIndex + ? locations.objArr.startIndex + objDelta + : locations.objArr.startIndex; + const objArrEnd = objArrStart + (locations.objArr.endIndex - locations.objArr.startIndex); + newFile = newFile.slice(0, objArrStart) + objArrText + newFile.slice(objArrEnd); + + const ident = locations.switchStatement.identifiers?.[0] ?? 'A'; + const switchLines: string[] = [`switch(${ident}){`]; + themes.forEach((theme) => { + switchLines.push(`case"${theme.id}":return${JSON.stringify(theme.colors)};`); + }); + switchLines.push(`default:return${JSON.stringify(themes[0].colors)};`); + switchLines.push('}'); + const switchText = switchLines.join('\n'); + + const objArrDelta = objArrText.length - (locations.objArr.endIndex - locations.objArr.startIndex); + let switchStart = locations.switchStatement.startIndex; + if (switchStart >= locations.obj.endIndex) switchStart += objDelta; + if (switchStart >= locations.objArr.endIndex) switchStart += objArrDelta; + const switchEnd = switchStart + (locations.switchStatement.endIndex - locations.switchStatement.startIndex); + newFile = newFile.slice(0, switchStart) + switchText + newFile.slice(switchEnd); + + return { js: newFile, replaced: 3 }; +}; diff --git a/src/core/binary-patcher/unpack-and-patch.ts b/src/core/binary-patcher/unpack-and-patch.ts new file mode 100644 index 0000000..fc5b5b4 --- /dev/null +++ b/src/core/binary-patcher/unpack-and-patch.ts @@ -0,0 +1,147 @@ +/** + * Unpack-and-patch path for macOS variants where the in-place Mach-O patch + * would grow the binary. + * + * Mach-O segment shifting + ad-hoc re-signing for grown binaries is non-trivial + * (~200-300 LoC of LC_SEGMENT_64 / LC_SYMTAB / LC_DYLD_INFO_ONLY field math). + * Instead, on macOS we extract the embedded JS modules from the pristine Bun + * binary, strip Bun's CommonJS wrapper from the entry, apply theme + prompt + * patches as plain string substitutions, install the four runtime deps that + * Bun externalizes (`ws`, `node-fetch`, `undici`, `yaml`) into a sibling + * node_modules, and switch the wrapper to invoke `node ` instead of + * the native binary. + * + * The pristine binary container itself is left untouched - macOS code-signing + * never enters the picture because we no longer execute that file. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import type { TweakccConfig } from '../../brands/types.js'; +import type { OverlayMap } from '../prompt-pack/types.js'; +import { extractAll, parseBunBinary } from '../bun-extract.js'; + +import { patchUnpackedEntry, type PatchUnpackedResult } from './js-patch.js'; + +/** + * The four packages that Anthropic's Bun bundler externalizes (verified + * empirically against CC 2.1.119: every other bare require() in the entry is + * a Node built-in). Pinned to current latest-major to track API compatibility + * without taking patch updates surprise. + */ +const RUNTIME_DEPENDENCIES: Record = { + ws: '^8.18.0', + 'node-fetch': '^3.3.2', + undici: '^7.0.0', + yaml: '^2.6.0', +}; + +export class UnpackAndPatchError extends Error { + constructor( + public readonly stage: 'extract' | 'patch' | 'deps', + message: string + ) { + super(`unpack-and-patch (${stage}): ${message}`); + this.name = 'UnpackAndPatchError'; + } +} + +export interface UnpackAndPatchInputs { + /** Pristine binary path - the SHA256-verified cache copy, not the variant's. */ + pristineBinaryPath: string; + /** Target directory for the extracted JS tree (`/unpacked`). */ + unpackedDir: string; + config: TweakccConfig; + overlays: OverlayMap | null; +} + +export interface UnpackAndPatchResult { + /** Absolute path of the patched entry module to feed into `node`. */ + entryPath: string; + patch: PatchUnpackedResult; +} + +/** + * Wipe `unpackedDir` if it exists, extract the pristine binary's JS modules + * into it, strip the Bun wrapper + apply theme + apply prompt overlays, write + * a package.json + run `npm install` for the externalized deps. Returns the + * patched entry path that the variant wrapper should hand to `node`. + */ +export const unpackAndPatch = ({ + pristineBinaryPath, + unpackedDir, + config, + overlays, +}: UnpackAndPatchInputs): UnpackAndPatchResult => { + // Always start from a clean tree so re-runs (update flow) get the same + // output as a from-scratch create. js-patch's anchors are not idempotent + // because applyTheme expects pristine upstream labels. + if (fs.existsSync(unpackedDir)) { + fs.rmSync(unpackedDir, { recursive: true, force: true }); + } + fs.mkdirSync(unpackedDir, { recursive: true }); + + let buf: Buffer; + try { + buf = fs.readFileSync(pristineBinaryPath); + } catch (err) { + throw new UnpackAndPatchError('extract', `read ${pristineBinaryPath}: ${(err as Error).message}`); + } + + let info; + try { + info = parseBunBinary(buf); + } catch (err) { + throw new UnpackAndPatchError('extract', `parseBunBinary: ${(err as Error).message}`); + } + + try { + extractAll(buf, info, unpackedDir, { manifest: true }); + } catch (err) { + throw new UnpackAndPatchError('extract', (err as Error).message); + } + + let patch: PatchUnpackedResult; + try { + patch = patchUnpackedEntry({ unpackedDir, config, overlays }); + } catch (err) { + throw new UnpackAndPatchError('patch', (err as Error).message); + } + + writePackageJson(unpackedDir); + runNpmInstall(unpackedDir); + + return { entryPath: patch.entryPath, patch }; +}; + +const writePackageJson = (unpackedDir: string): void => { + const pkg = { + name: 'cc-mirror-unpacked', + version: '0.0.0', + private: true, + description: + 'Auto-generated by cc-mirror to host runtime dependencies that Bun externalizes from the standalone CC binary. Do not edit by hand.', + dependencies: RUNTIME_DEPENDENCIES, + }; + fs.writeFileSync(path.join(unpackedDir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); +}; + +const runNpmInstall = (unpackedDir: string): void => { + const result = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund'], { + cwd: unpackedDir, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + + if (result.error) { + throw new UnpackAndPatchError('deps', `npm install failed to launch: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = (result.stderr ?? '').trim(); + const stdout = (result.stdout ?? '').trim(); + const detail = stderr || stdout || `exit ${result.status}`; + throw new UnpackAndPatchError('deps', `npm install exit ${result.status}: ${detail}`); + } +}; diff --git a/src/core/bun-extract.ts b/src/core/bun-extract.ts new file mode 100644 index 0000000..85804a1 --- /dev/null +++ b/src/core/bun-extract.ts @@ -0,0 +1,393 @@ +/** + * Bun standalone-binary extractor. + * + * Parses Mach-O __BUN, ELF appended-trailer, and PE .bun section layouts to + * recover the embedded StandaloneModuleGraph (source modules, sourcemaps, + * bytecode caches) without relying on tweakcc / node-lief. + * + * Same-size in-place module replacement is supported via replaceModule. + * Resizing the entry-point module is owned by src/core/binary-patcher + * (replaceEntryJs), which uses parseBunBinary's output and the constants + * exported from this file to rewrite the module table and platform headers. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { + BUNFS_PATH_PREFIXES, + ENCODING_NAMES, + FLAG_OFFSETS_BY_SIZE, + FORMAT_NAMES, + LOADER_NAMES, + MODULE_SIZES, + MODULE_SIZE_V52, + OFFSETS_SIZE, + TRAILER, + TRAILER_SEARCH_WINDOW, + type Encoding, + type LoaderKind, + type ModuleFormat, + type ModuleSize, + type Side, +} from './bun-extract/constants.js'; +import { elfDataStart, isElf } from './bun-extract/elf.js'; +import { findBunSection, isMacho, machoDataStart } from './bun-extract/macho.js'; +import { findBunPeSection, isPe, peDataStart } from './bun-extract/pe.js'; + +export type BunPlatform = 'macho' | 'elf' | 'pe'; + +export interface BunModule { + index: number; + name: string; + contOff: number; + contLen: number; + smapOff: number; + smapLen: number; + bcOff: number; + bcLen: number; + encoding: Encoding | number; + loader: LoaderKind | number; + format: ModuleFormat | number; + side: Side; + isEntry: boolean; +} + +export interface BunBinaryInfo { + platform: BunPlatform; + dataStart: number; + trailerOffset: number; + byteCount: number; + moduleSize: ModuleSize; + modules: BunModule[]; + entryPointId: number; + flags: number; + sectionOffset?: number; + sectionSize?: number; + hasCodeSignature: boolean; + bunVersionHint: 'pre-1.3.13' | '>=1.3.13'; +} + +export interface ExtractAllOptions { + writeSourcemaps?: boolean; + manifest?: boolean; +} + +export interface ExtractAllResult { + written: string[]; + manifestPath?: string; +} + +export interface ReplaceResult { + buf: Buffer; + signatureInvalidated: boolean; +} + +export class BunFormatError extends Error { + constructor(message: string) { + super(message); + this.name = 'BunFormatError'; + } +} + +export class ModuleNotFound extends Error { + constructor(moduleName: string) { + super(`Module not found in Bun binary: ${moduleName}`); + this.name = 'ModuleNotFound'; + } +} + +export class SizeMismatch extends Error { + constructor(moduleName: string, expected: number, actual: number) { + super( + `replaceModule requires same-size content for "${moduleName}". Expected ${expected} bytes, got ${actual}. Resizing replacements would shift downstream offsets and require module-table rebuilds (out of scope).` + ); + this.name = 'SizeMismatch'; + } +} + +export const parseBunBinary = (buf: Buffer): BunBinaryInfo => { + const trailerOffset = findTrailer(buf); + if (trailerOffset < 0) { + throw new BunFormatError(`Bun trailer "\\n---- Bun! ----\\n" not found in last ${TRAILER_SEARCH_WINDOW} bytes`); + } + + const offsetsStart = trailerOffset - OFFSETS_SIZE; + if (offsetsStart < 0) { + throw new BunFormatError(`Trailer at offset ${trailerOffset} leaves no room for Offsets struct`); + } + + const byteCount = Number(buf.readBigUInt64LE(offsetsStart)); + const modulesOff = buf.readUInt32LE(offsetsStart + 8); + const modulesLen = buf.readUInt32LE(offsetsStart + 12); + const entryPointId = buf.readUInt32LE(offsetsStart + 16); + const flags = buf.readUInt32LE(offsetsStart + 28); + + // Resolve platform + dataStart. + let platform: BunPlatform; + let dataStart: number; + let sectionOffset: number | undefined; + let sectionSize: number | undefined; + let hasCodeSignature = false; + + if (isMacho(buf)) { + platform = 'macho'; + const section = findBunSection(buf); + if (section) { + sectionOffset = section.sectionOffset; + sectionSize = section.sectionSize; + hasCodeSignature = section.hasCodeSignature; + dataStart = machoDataStart(section.sectionOffset); + } else { + // Fall back to trailer-derived computation if section scan failed. + dataStart = elfDataStart(trailerOffset, byteCount); + } + } else if (isPe(buf)) { + platform = 'pe'; + const section = findBunPeSection(buf); + if (section) { + sectionOffset = section.pointerToRawData; + sectionSize = section.sizeOfRawData; + dataStart = peDataStart(section.pointerToRawData); + } else { + dataStart = elfDataStart(trailerOffset, byteCount); + } + } else if (isElf(buf)) { + platform = 'elf'; + dataStart = elfDataStart(trailerOffset, byteCount); + } else { + // Unknown header but still has a Bun trailer: treat as ELF-style appended data. + platform = 'elf'; + dataStart = elfDataStart(trailerOffset, byteCount); + } + + if (dataStart < 0 || dataStart + byteCount > buf.length) { + throw new BunFormatError( + `Computed dataStart=${dataStart} byteCount=${byteCount} is out of range for binary of length ${buf.length}` + ); + } + + // Try each known module struct size, validating against the data we just located. + let result: { moduleSize: ModuleSize; modules: BunModule[] } | null = null; + const errors: string[] = []; + for (const candidate of MODULE_SIZES) { + if (modulesLen % candidate !== 0) { + errors.push(`size=${candidate}: modulesLen=${modulesLen} not divisible`); + continue; + } + try { + const modules = readModuleTable(buf, dataStart, modulesOff, modulesLen, candidate, entryPointId, byteCount); + result = { moduleSize: candidate, modules }; + break; + } catch (err) { + errors.push(`size=${candidate}: ${(err as Error).message}`); + } + } + + if (!result) { + throw new BunFormatError(`Could not parse module table at any known struct size. Attempts: ${errors.join('; ')}`); + } + + return { + platform, + dataStart, + trailerOffset, + byteCount, + moduleSize: result.moduleSize, + modules: result.modules, + entryPointId, + flags, + sectionOffset, + sectionSize, + hasCodeSignature, + bunVersionHint: result.moduleSize === MODULE_SIZE_V52 ? '>=1.3.13' : 'pre-1.3.13', + }; +}; + +const findTrailer = (buf: Buffer): number => { + const minStart = Math.max(0, buf.length - TRAILER_SEARCH_WINDOW); + for (let i = buf.length - TRAILER.length; i >= minStart; i -= 1) { + if (buf[i] === TRAILER[0] && buf.subarray(i, i + TRAILER.length).equals(TRAILER)) { + return i; + } + } + return -1; +}; + +const readModuleTable = ( + buf: Buffer, + dataStart: number, + modulesOff: number, + modulesLen: number, + moduleSize: ModuleSize, + entryPointId: number, + byteCount: number +): BunModule[] => { + const numModules = modulesLen / moduleSize; + const flagsBase = FLAG_OFFSETS_BY_SIZE[moduleSize]; + const modules: BunModule[] = []; + + for (let i = 0; i < numModules; i += 1) { + const base = dataStart + modulesOff + i * moduleSize; + if (base + moduleSize > buf.length) { + throw new Error(`module ${i} extends past EOF`); + } + + const nameOff = buf.readUInt32LE(base); + const nameLen = buf.readUInt32LE(base + 4); + const contOff = buf.readUInt32LE(base + 8); + const contLen = buf.readUInt32LE(base + 12); + const smapOff = buf.readUInt32LE(base + 16); + const smapLen = buf.readUInt32LE(base + 20); + const bcOff = buf.readUInt32LE(base + 24); + const bcLen = buf.readUInt32LE(base + 28); + + if (nameLen === 0 || nameLen > 4096) { + throw new Error(`module ${i} has implausible nameLen=${nameLen}`); + } + if (nameOff + nameLen > byteCount) { + throw new Error(`module ${i} name extends past byteCount`); + } + if (contOff + contLen > byteCount) { + throw new Error(`module ${i} content extends past byteCount`); + } + + const nameBytes = buf.subarray(dataStart + nameOff, dataStart + nameOff + nameLen); + if (!isPlausibleNameBytes(nameBytes)) { + throw new Error(`module ${i} name is not a plausible path`); + } + + const encByte = buf[base + flagsBase]; + const loaderByte = buf[base + flagsBase + 1]; + const formatByte = buf[base + flagsBase + 2]; + const sideByte = buf[base + flagsBase + 3]; + + modules.push({ + index: i, + name: stripBunfs(nameBytes.toString('utf8')), + contOff, + contLen, + smapOff, + smapLen, + bcOff, + bcLen, + encoding: ENCODING_NAMES[encByte] ?? encByte, + loader: LOADER_NAMES[loaderByte] ?? loaderByte, + format: FORMAT_NAMES[formatByte] ?? formatByte, + side: sideByte === 1 ? 'client' : 'server', + isEntry: i === entryPointId, + }); + } + + return modules; +}; + +const stripBunfs = (raw: string): string => { + for (const prefix of BUNFS_PATH_PREFIXES) { + if (raw.startsWith(prefix)) return raw.slice(prefix.length); + } + return raw; +}; + +const isPlausibleNameBytes = (bytes: Buffer): boolean => { + if (bytes.length === 0) return false; + for (let i = 0; i < bytes.length; i += 1) { + const b = bytes[i]; + // Allow printable ASCII, plus common UTF-8 continuation/lead bytes for non-ASCII paths. + if (b === 0) return false; + if (b < 0x20 && b !== 0x09) return false; + if (b === 0x7f) return false; + } + return true; +}; + +export const extractAll = ( + buf: Buffer, + info: BunBinaryInfo, + outDir: string, + opts: ExtractAllOptions = {} +): ExtractAllResult => { + const written: string[] = []; + fs.mkdirSync(outDir, { recursive: true }); + + for (const mod of info.modules) { + if (mod.contLen === 0) continue; + const outPath = path.join(outDir, sanitizeRelPath(mod.name)); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + const slice = buf.subarray(info.dataStart + mod.contOff, info.dataStart + mod.contOff + mod.contLen); + fs.writeFileSync(outPath, slice); + written.push(outPath); + + if (opts.writeSourcemaps && mod.smapLen > 0) { + const smap = buf.subarray(info.dataStart + mod.smapOff, info.dataStart + mod.smapOff + mod.smapLen); + const smapPath = `${outPath}.map`; + fs.writeFileSync(smapPath, smap); + written.push(smapPath); + } + } + + let manifestPath: string | undefined; + if (opts.manifest !== false) { + manifestPath = path.join(outDir, 'manifest.json'); + fs.writeFileSync( + manifestPath, + JSON.stringify( + { + platform: info.platform, + moduleSize: info.moduleSize, + bunVersionHint: info.bunVersionHint, + entryPoint: info.modules[info.entryPointId]?.name, + entryPointId: info.entryPointId, + flags: info.flags, + modules: info.modules.map((m) => ({ + index: m.index, + name: m.name, + sourceSize: m.contLen, + bytecodeSize: m.bcLen, + sourcemapSize: m.smapLen, + isEntry: m.isEntry, + encoding: m.encoding, + loader: m.loader, + format: m.format, + side: m.side, + })), + }, + null, + 2 + ) + ); + } + + return { written, manifestPath }; +}; + +// Block path traversal via .. segments and absolute paths. Bun's $bunfs/root paths +// shouldn't contain these post-strip, but defense-in-depth keeps unpack safe on +// tampered binaries. +const sanitizeRelPath = (rel: string): string => { + const normalized = rel.replace(/\\/g, '/'); + const stripped = normalized.replace(/^\/+/, ''); + if (stripped.split('/').some((seg) => seg === '..')) { + throw new BunFormatError(`Refusing to extract module with traversal path: ${rel}`); + } + return stripped; +}; + +export const replaceModule = ( + buf: Buffer, + info: BunBinaryInfo, + moduleName: string, + newContent: Buffer +): ReplaceResult => { + const target = info.modules.find((m) => m.name === moduleName); + if (!target) throw new ModuleNotFound(moduleName); + if (newContent.length !== target.contLen) { + throw new SizeMismatch(moduleName, target.contLen, newContent.length); + } + const out = Buffer.from(buf); + newContent.copy(out, info.dataStart + target.contOff); + return { + buf: out, + signatureInvalidated: info.platform === 'macho' && info.hasCodeSignature, + }; +}; diff --git a/src/core/bun-extract/constants.ts b/src/core/bun-extract/constants.ts new file mode 100644 index 0000000..5f7b0f6 --- /dev/null +++ b/src/core/bun-extract/constants.ts @@ -0,0 +1,88 @@ +/** + * Bun standalone-binary format constants. + * + * Format reference: https://github.com/oven-sh/bun src/StandaloneModuleGraph.zig + * + * Layout (per platform): + * macOS - __BUN section in a Mach-O segment, 8-byte u64 size header at section start. + * Linux - .bun ELF PROGBITS section, 8-byte u64 size header at section start. + * Windows- .bun PE section, no 8-byte size header. + * + * All platforms end with [Offsets struct (32 bytes)][trailer "\n---- Bun! ----\n"]. + */ + +export const TRAILER = Buffer.from('\n---- Bun! ----\n'); + +/** Size of the Offsets struct that precedes the trailer. */ +export const OFFSETS_SIZE = 32; + +/** Mach-O and ELF each prefix their Bun section payload with an 8-byte u64 LE size; PE doesn't. */ +export const MACHO_SECTION_HEADER_SIZE = 8; + +/** + * Compiled module struct sizes by Bun version. + * + * Both layouts share the first 32 bytes (4 StringPointer pairs: + * name, content, sourcemap, bytecode). + * + * v36 (pre-1.3.13): 32 bytes of pointers + 4 bytes of flags. + * v52 (>=1.3.13): 32 bytes of pointers + 16 bytes of extra pointers + * (module_info, bytecode_origin_path) + 4 bytes of flags. + */ +export const MODULE_SIZE_V36 = 36; +export const MODULE_SIZE_V52 = 52; + +export type ModuleSize = typeof MODULE_SIZE_V36 | typeof MODULE_SIZE_V52; + +export const MODULE_SIZES: ModuleSize[] = [MODULE_SIZE_V52, MODULE_SIZE_V36]; + +/** Flag byte offsets (encoding/loader/format/side) within the module struct. */ +export const FLAG_OFFSETS_BY_SIZE: Record = { + [MODULE_SIZE_V36]: 32, + [MODULE_SIZE_V52]: 48, +}; + +/** Backward search window for the trailer; skips macOS code-signature padding. */ +export const TRAILER_SEARCH_WINDOW = 4 * 1024 * 1024; + +/** Cap on first-bytes scan when locating the Mach-O __BUN section heuristically. */ +export const MACHO_HEADER_SCAN_BYTES = 8192; + +/** Mach-O magic bytes (LE). */ +export const MACHO_MAGIC_64 = 0xfeedfacf; +export const MACHO_MAGIC_64_BE = 0xcffaedfe; +export const MACHO_MAGIC_FAT = 0xcafebabe; +export const MACHO_MAGIC_FAT_LE = 0xbebafeca; + +/** ELF magic: 0x7F 'E' 'L' 'F'. */ +export const ELF_MAGIC_BYTES = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); + +/** PE: 'MZ' DOS header magic. */ +export const PE_DOS_MAGIC = 0x5a4d; // 'MZ' little-endian read as u16 +export const PE_NT_SIGNATURE = 0x00004550; // 'PE\0\0' + +export type Encoding = 'binary' | 'latin1' | 'utf8'; +export type ModuleFormat = 'none' | 'esm' | 'cjs'; +export type Side = 'server' | 'client'; +export type LoaderKind = 'file' | 'js' | 'wasm' | 'napi'; + +export const ENCODING_NAMES: Record = { + 0: 'binary', + 1: 'latin1', + 2: 'utf8', +}; + +export const FORMAT_NAMES: Record = { + 0: 'none', + 1: 'esm', + 2: 'cjs', +}; + +export const LOADER_NAMES: Record = { + 0: 'file', + 1: 'js', + 9: 'wasm', + 10: 'napi', +}; + +export const BUNFS_PATH_PREFIXES = ['/$bunfs/root/', '$bunfs/root/']; diff --git a/src/core/bun-extract/elf.ts b/src/core/bun-extract/elf.ts new file mode 100644 index 0000000..d106940 --- /dev/null +++ b/src/core/bun-extract/elf.ts @@ -0,0 +1,26 @@ +import { ELF_MAGIC_BYTES, OFFSETS_SIZE } from './constants.js'; + +export const isElf = (buf: Buffer): boolean => + buf.length >= ELF_MAGIC_BYTES.length && buf.subarray(0, ELF_MAGIC_BYTES.length).equals(ELF_MAGIC_BYTES); + +/** + * For ELF binaries, Bun appends [data][module table][Offsets][trailer] at EOF. + * + * StringPointer offsets in the module table are relative to the start of the + * raw_bytes region that Bun's runtime passes to StandaloneModuleGraph.fromBytes. + * That region is [data .. module table .. Offsets struct .. trailer], so its + * total size is byteCount + OFFSETS_SIZE + trailer.length. + * + * byteCount in the Offsets struct excludes the Offsets struct and the trailer + * (per StandaloneModuleGraph.zig: "the length of the module graph with padding, + * excluding the trailer and offsets"). + * + * Therefore the start of raw_bytes is: + * trailerOffset - byteCount - OFFSETS_SIZE + * + * The previous formula `trailerOffset + trailerLen - byteCount` lands + * `OFFSETS_SIZE + trailer.length` (48) bytes past the real start, which is why + * names came back as random JS slices on Linux ELF binaries. + */ +export const elfDataStart = (trailerOffset: number, byteCount: number): number => + trailerOffset - byteCount - OFFSETS_SIZE; diff --git a/src/core/bun-extract/macho.ts b/src/core/bun-extract/macho.ts new file mode 100644 index 0000000..2c793e7 --- /dev/null +++ b/src/core/bun-extract/macho.ts @@ -0,0 +1,90 @@ +import { + MACHO_HEADER_SCAN_BYTES, + MACHO_MAGIC_64, + MACHO_MAGIC_64_BE, + MACHO_MAGIC_FAT, + MACHO_MAGIC_FAT_LE, + MACHO_SECTION_HEADER_SIZE, +} from './constants.js'; + +export interface MachoSection { + sectionOffset: number; + sectionSize: number; + /** Whether the binary contains an LC_CODE_SIGNATURE load command (best-effort scan). */ + hasCodeSignature: boolean; +} + +const LC_CODE_SIGNATURE = 0x1d; + +export const isMacho = (buf: Buffer): boolean => { + if (buf.length < 4) return false; + const magic = buf.readUInt32LE(0); + return ( + magic === MACHO_MAGIC_64 || magic === MACHO_MAGIC_64_BE || magic === MACHO_MAGIC_FAT || magic === MACHO_MAGIC_FAT_LE + ); +}; + +/** + * Locate the __BUN/__bun section by scanning the first MACHO_HEADER_SCAN_BYTES + * for the literal section_64 layout: sectname[16] = "__bun\0...", segname[16] = "__BUN\0..." + * + * This is the same heuristic vicnaum/bun-demincer uses. It avoids walking the full + * load-command table, but is reliable in practice because Bun emits exactly one + * such section at a deterministic location. + * + * Returns null if no match is found (caller falls back to the trailer-driven path). + */ +export const findBunSection = (buf: Buffer): MachoSection | null => { + const limit = Math.min(buf.length, MACHO_HEADER_SCAN_BYTES); + let sectionOffset = -1; + let sectionSize = 0; + + for (let i = 0; i < limit - 56; i += 1) { + // sectname "__bun\0" + if ( + buf[i] === 0x5f && + buf[i + 1] === 0x5f && + buf[i + 2] === 0x62 && + buf[i + 3] === 0x75 && + buf[i + 4] === 0x6e && + buf[i + 5] === 0x00 && + // segname "__BUN" 16 bytes later + buf[i + 16] === 0x5f && + buf[i + 17] === 0x5f && + buf[i + 18] === 0x42 && + buf[i + 19] === 0x55 && + buf[i + 20] === 0x4e + ) { + // section_64 layout: sectname[16] segname[16] addr(u64) size(u64) offset(u32) align(u32) ... + sectionSize = Number(buf.readBigUInt64LE(i + 40)); + sectionOffset = buf.readUInt32LE(i + 48); + break; + } + } + + if (sectionOffset < 0) return null; + return { + sectionOffset, + sectionSize, + hasCodeSignature: scanForCodeSignatureCmd(buf, limit), + }; +}; + +/** + * Best-effort detection: scan the load-command region for an LC_CODE_SIGNATURE marker. + * We only need a flag for write-back warnings, not exact offsets, so an exact load-command + * walk isn't required. + */ +const scanForCodeSignatureCmd = (buf: Buffer, limit: number): boolean => { + for (let i = 0; i < limit - 8; i += 4) { + if (buf.readUInt32LE(i) === LC_CODE_SIGNATURE) { + const size = buf.readUInt32LE(i + 4); + // Real LC_CODE_SIGNATURE has cmdsize 16. Use that as a weak signal to avoid false hits. + if (size === 16) return true; + } + } + return false; +}; + +/** Mach-O dataStart sits past the 8-byte u64 size prefix at the section start. */ +export const machoDataStart = (sectionOffset: number): number => sectionOffset + MACHO_SECTION_HEADER_SIZE; diff --git a/src/core/bun-extract/pe.ts b/src/core/bun-extract/pe.ts new file mode 100644 index 0000000..5940d8e --- /dev/null +++ b/src/core/bun-extract/pe.ts @@ -0,0 +1,53 @@ +import { PE_DOS_MAGIC, PE_NT_SIGNATURE } from './constants.js'; + +export interface PeSection { + pointerToRawData: number; + sizeOfRawData: number; +} + +export const isPe = (buf: Buffer): boolean => buf.length >= 2 && buf.readUInt16LE(0) === PE_DOS_MAGIC; + +/** + * Locate the .bun PE section by walking the section table. + * + * PE layout: + * DOS header at 0; e_lfanew (u32 LE) at 0x3C points to NT headers. + * NT headers: PE\0\0 (4) + COFF FileHeader (20) + OptionalHeader (variable). + * FileHeader.NumberOfSections (u16) at PE+0x06. + * FileHeader.SizeOfOptionalHeader (u16) at PE+0x14. + * Section headers (40 bytes each) start right after the optional header. + * + * Returns null if .bun is absent. + */ +export const findBunPeSection = (buf: Buffer): PeSection | null => { + if (buf.length < 0x40) return null; + const peOff = buf.readUInt32LE(0x3c); + if (peOff <= 0 || peOff + 24 > buf.length) return null; + if (buf.readUInt32LE(peOff) !== PE_NT_SIGNATURE) return null; + + const numSections = buf.readUInt16LE(peOff + 6); + const sizeOfOptional = buf.readUInt16LE(peOff + 20); + const sectionsStart = peOff + 24 + sizeOfOptional; + + for (let i = 0; i < numSections; i += 1) { + const base = sectionsStart + i * 40; + if (base + 40 > buf.length) return null; + const name = buf.subarray(base, base + 8); + // Section name is null-padded to 8 bytes; first 5 should be '.bun\0'. + if ( + name[0] === 0x2e && // '.' + name[1] === 0x62 && // 'b' + name[2] === 0x75 && // 'u' + name[3] === 0x6e && // 'n' + name[4] === 0x00 + ) { + const sizeOfRawData = buf.readUInt32LE(base + 16); + const pointerToRawData = buf.readUInt32LE(base + 20); + return { pointerToRawData, sizeOfRawData }; + } + } + return null; +}; + +/** PE has no 8-byte size header at the start of the section. */ +export const peDataStart = (pointerToRawData: number): number => pointerToRawData; diff --git a/src/core/constants.ts b/src/core/constants.ts index fde30d2..4f69f76 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -4,7 +4,6 @@ import path from 'node:path'; export const DEFAULT_ROOT = path.join(os.homedir(), '.cc-mirror'); export const DEFAULT_BIN_DIR = process.platform === 'win32' ? path.join(DEFAULT_ROOT, 'bin') : path.join(os.homedir(), '.local', 'bin'); -export const TWEAKCC_VERSION = '4.0.11'; // Claude Code version/channel used for installs unless overridden. // "stable" tracks the upstream stable channel; "latest" tracks newest releases. export const DEFAULT_CLAUDE_VERSION = 'latest'; diff --git a/src/core/errors.ts b/src/core/errors.ts deleted file mode 100644 index 9dca16d..0000000 --- a/src/core/errors.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const isTweakccNativeExtractionFailure = (text: string) => { - const normalized = text.toLowerCase(); - return ( - normalized.includes('could not extract js from native binary') || - normalized.includes('failed to extract claude.js from native installation') || - normalized.includes('failed to extract javascript from native installation') - ); -}; - -const extractErrorHint = (text: string) => { - const normalized = text.toLowerCase(); - if (isTweakccNativeExtractionFailure(text)) { - return 'tweakcc could not extract JS from the native Claude Code binary. This usually means the pinned tweakcc/native extractor (often node-lief) cannot read this Claude Code release yet. Re-run with --no-tweak to skip theming, or update cc-mirror/tweakcc to a version that supports this binary.'; - } - if (normalized.includes('node-lief')) { - return 'tweakcc requires native extraction support such as node-lief to patch native Claude Code binaries. If that extractor cannot be installed or loaded on your system, re-run with --no-tweak to skip theming.'; - } - return null; -}; - -export const formatTweakccFailure = (output: string) => { - const hint = extractErrorHint(output); - if (hint) return hint; - - const lines = output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - if (lines.length === 0) return 'tweakcc failed.'; - - const errorLine = lines.find((line) => line.toLowerCase().startsWith('error:')); - if (errorLine) return errorLine; - - const tail = lines.slice(-3).join(' | '); - return tail.length > 0 ? tail : 'tweakcc failed.'; -}; diff --git a/src/core/index.ts b/src/core/index.ts index 8e96476..e2cfa00 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,15 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { DEFAULT_BIN_DIR, DEFAULT_CLAUDE_VERSION, DEFAULT_CLAUDE_NATIVE_CACHE_DIR, DEFAULT_ROOT } from './constants.js'; -import { ensureDir } from './fs.js'; import { expandTilde, getWrapperPath, getWrapperScriptPath, isWindows } from './paths.js'; -import { ensureTweakccConfig, launchTweakccUi } from './tweakcc.js'; -import { formatTweakccFailure } from './errors.js'; -import { listVariants as listVariantsImpl, loadVariantMeta } from './variants.js'; +import { listVariants as listVariantsImpl } from './variants.js'; import { VariantBuilder, VariantUpdater } from './variant-builder/index.js'; +import { parseBunBinary } from './bun-extract.js'; import type { CreateVariantParams, CreateVariantResult, + DoctorBunInfo, DoctorReportItem, UpdateVariantOptions, UpdateVariantResult, @@ -52,6 +51,29 @@ export const removeVariant = (rootDir: string, name: string) => { fs.rmSync(variantDir, { recursive: true, force: true }); }; +// Skip Bun parsing for binaries above this size to keep `doctor` snappy. +// Real Claude Code builds are well under this; anything larger is suspicious. +const DOCTOR_BUN_PARSE_MAX_BYTES = 500 * 1024 * 1024; + +const inspectBunBinary = (binaryPath: string): DoctorBunInfo | undefined => { + try { + const stat = fs.statSync(binaryPath); + if (stat.size > DOCTOR_BUN_PARSE_MAX_BYTES) return undefined; + const buf = fs.readFileSync(binaryPath); + const info = parseBunBinary(buf); + return { + platform: info.platform, + moduleSize: info.moduleSize, + moduleCount: info.modules.length, + entryPath: info.modules[info.entryPointId]?.name, + bunVersionHint: info.bunVersionHint, + hasCodeSignature: info.hasCodeSignature, + }; + } catch (err) { + return { platform: 'elf', moduleSize: 0, moduleCount: 0, error: (err as Error).message }; + } +}; + export const doctor = (rootDir: string, binDir: string): DoctorReportItem[] => { const resolvedRoot = expandTilde(rootDir || DEFAULT_ROOT) ?? rootDir; const resolvedBin = expandTilde(binDir || DEFAULT_BIN_DIR) ?? binDir; @@ -60,12 +82,14 @@ export const doctor = (rootDir: string, binDir: string): DoctorReportItem[] => { const wrapperPath = getWrapperPath(resolvedBin, name); const wrapperOk = fs.existsSync(wrapperPath); const scriptOk = !isWindows || fs.existsSync(getWrapperScriptPath(resolvedBin, name)); - const ok = Boolean(meta && fs.existsSync(meta.binaryPath) && wrapperOk && scriptOk); + const binaryExists = Boolean(meta && fs.existsSync(meta.binaryPath)); + const ok = Boolean(meta && binaryExists && wrapperOk && scriptOk); return { name, ok, binaryPath: meta?.binaryPath, wrapperPath, + bunInfo: binaryExists && meta ? inspectBunBinary(meta.binaryPath) : undefined, }; }); }; @@ -74,18 +98,3 @@ export const listVariants = (rootDir: string): VariantEntry[] => { const resolvedRoot = expandTilde(rootDir || DEFAULT_ROOT) ?? rootDir; return listVariantsImpl(resolvedRoot); }; - -export const tweakVariant = (rootDir: string, name: string): void => { - const resolvedRoot = expandTilde(rootDir || DEFAULT_ROOT) ?? rootDir; - const variantDir = path.join(resolvedRoot, name); - const meta = loadVariantMeta(variantDir); - if (!meta) throw new Error(`Variant not found: ${name}`); - ensureDir(meta.tweakDir); - const brandKey = meta.brand ?? null; - ensureTweakccConfig(meta.tweakDir, brandKey); - const result = launchTweakccUi(meta.tweakDir, meta.binaryPath); - if (result.status && result.status !== 0) { - const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.trim(); - throw new Error(formatTweakccFailure(output)); - } -}; diff --git a/src/core/install.ts b/src/core/install.ts index 602b420..a1cb800 100644 --- a/src/core/install.ts +++ b/src/core/install.ts @@ -21,7 +21,7 @@ const assertValidClaudeVersion = (value: string) => { } }; -const resolveNativePlatformKey = (): string => { +export const resolveNativePlatformKey = (): string => { const os = process.platform; const arch = process.arch; @@ -252,3 +252,53 @@ export const installNativeClaudeAsync = async (params: { return { binaryPath, resolvedVersion, platform }; }; + +export interface RestorePristineResult { + restored: boolean; + cachePath?: string; + reason?: 'cache-missing' | 'copy-failed'; + error?: Error; +} + +/** + * Copy the cached pristine native binary back to the variant. Used when a + * downstream step (e.g. tweakcc) corrupts the binary and we need to roll + * forward by reverting to the SHA256-verified pristine copy already in cache. + * + * Cache layout matches installNativeClaudeAsync exactly: + * ///claude (or claude.exe on Windows) + */ +export const restorePristineBinary = (params: { + binaryPath: string; + cacheDir: string; + resolvedVersion: string; + platform: string; +}): RestorePristineResult => { + const cacheRoot = params.cacheDir?.trim(); + if (!cacheRoot || !params.resolvedVersion || !params.platform) { + return { restored: false, reason: 'cache-missing' }; + } + const cachePath = resolveNativeClaudePath(path.join(cacheRoot, params.resolvedVersion, params.platform)); + if (!fs.existsSync(cachePath)) { + return { restored: false, cachePath, reason: 'cache-missing' }; + } + try { + fs.rmSync(params.binaryPath, { force: true }); + fs.copyFileSync(cachePath, params.binaryPath); + if (process.platform !== 'win32') { + try { + fs.chmodSync(params.binaryPath, 0o755); + } catch { + // ignore chmod failure (e.g. exotic filesystems); copy already succeeded. + } + } + return { restored: true, cachePath }; + } catch (err) { + return { + restored: false, + cachePath, + reason: 'copy-failed', + error: err instanceof Error ? err : new Error(String(err)), + }; + } +}; diff --git a/src/core/tweakcc.ts b/src/core/tweakcc.ts index c041c00..104d930 100644 --- a/src/core/tweakcc.ts +++ b/src/core/tweakcc.ts @@ -1,58 +1,101 @@ +/** + * Helpers shared between Phase 1 (TweakccStep, removed) and Phase 2 + * (BinaryPatcherStep): brand config writer, post-patch smoke test, rollback + * note formatter, failure type. The npx-tweakcc shell-out path is gone. + * + * Note: file is named tweakcc.ts for historical reasons. ensureTweakccConfig + * still writes a tweakcc-style config.json that the in-repo binary patcher + * reads as TweakccConfig - the schema lives in src/brands/types.ts. + */ + import fs from 'node:fs'; -import { spawn, spawnSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { buildBrandConfig } from '../brands/index.js'; import type { MiscConfig, TweakccSettings } from '../brands/types.js'; -import { TWEAKCC_VERSION } from './constants.js'; -import { isTweakccNativeExtractionFailure } from './errors.js'; -import { commandExists, isWindows } from './paths.js'; -import type { TweakResult } from './types.js'; - -export type TweakccResult = TweakResult; -const TWEAKCC_LATEST_SPEC = 'latest'; -export const getNpxCommand = (): string => (isWindows ? 'npx.cmd' : 'npx'); - -const buildNpxInvocation = (args: string[]): { cmd: string; args: string[] } => { - const npxCmd = getNpxCommand(); - if (isWindows) { - return { cmd: 'cmd.exe', args: ['/d', '/s', '/c', npxCmd, ...args] }; +export interface SmokeTestResult { + ok: boolean; + status: number | null; + signal: NodeJS.Signals | null; + stderr: string; + stdout: string; + timedOut: boolean; + error?: string; +} + +const SMOKE_TIMEOUT_MS = 5000; + +/** + * Spawn ` --version` and confirm it exits 0 within timeoutMs. + * + * Used post-patch to detect a corrupted Bun standalone binary before we + * write the wrapper. Catches Bun-version regressions (e.g., 1.3.13 darwin + * CJS-wrapper assertion) and unsigned-binary AMFI kills on macOS. + */ +export const smokeTestBinary = (binaryPath: string, timeoutMs: number = SMOKE_TIMEOUT_MS): SmokeTestResult => { + let result; + try { + result = spawnSync(binaryPath, ['--version'], { + timeout: timeoutMs, + stdio: 'pipe', + encoding: 'utf8', + windowsHide: true, + }); + } catch (err) { + return { + ok: false, + status: null, + signal: null, + stderr: '', + stdout: '', + timedOut: false, + error: err instanceof Error ? err.message : String(err), + }; } - return { cmd: npxCmd, args }; -}; - -const buildTweakccInvocation = (versionSpec: string, args: string[]) => - buildNpxInvocation([`tweakcc@${versionSpec}`, ...args]); - -const getCombinedOutput = (result: { stderr?: string; stdout?: string }) => - `${result.stderr ?? ''}\n${result.stdout ?? ''}`.trim(); -const withTweakccMetadata = ( - result: TweakccResult, - tweakccSpec: string, - fallbackFromTweakccSpec?: string -): TweakccResult => ({ - ...result, - tweakccSpec, - fallbackFromTweakccSpec, -}); - -const shouldRetryWithLatest = (result: TweakccResult, tweakccSpec: string) => - tweakccSpec !== TWEAKCC_LATEST_SPEC && - result.status !== 0 && - isTweakccNativeExtractionFailure(getCombinedOutput(result)); - -const writeFallbackNotice = (fromSpec: string, toSpec: string) => { - process.stderr.write( - `cc-mirror: tweakcc@${fromSpec} could not read this Claude Code binary; retrying with tweakcc@${toSpec}.\n` - ); + const timedOut = Boolean(result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT'); + const signal = (result.signal ?? null) as NodeJS.Signals | null; + const status = result.status ?? null; + + return { + ok: status === 0 && !timedOut && !signal && !result.error, + status, + signal, + stderr: result.stderr ?? '', + stdout: result.stdout ?? '', + timedOut, + error: result.error?.message, + }; }; -export const getTweakccFallbackNote = (result: TweakccResult | null | undefined): string | null => { - const fallbackFrom = result?.fallbackFromTweakccSpec?.trim(); - const used = result?.tweakccSpec?.trim(); - if (!fallbackFrom || !used || fallbackFrom === used) return null; - return `Pinned tweakcc@${fallbackFrom} could not read this Claude Code binary; automatically retried with tweakcc@${used}.`; +export type TweakccPatchFailure = + | { kind: 'tweakcc-failed'; output: string; tweakccSpec?: string } + | { kind: 'smoke-failed'; smoke: SmokeTestResult; tweakccSpec?: string }; + +const tail3 = (text: string): string => + text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .slice(-3) + .join(' | '); + +export const formatRollbackNote = (fail: TweakccPatchFailure): string => { + if (fail.kind === 'smoke-failed') { + const reason = fail.smoke.timedOut + ? 'binary hung' + : fail.smoke.signal + ? `killed by ${fail.smoke.signal}` + : fail.smoke.error + ? `spawn error: ${fail.smoke.error}` + : `exit ${fail.smoke.status}`; + const detail = tail3(fail.smoke.stderr); + const suffix = detail ? `: ${detail}` : ''; + return `tweakcc patch corrupted the binary (${reason}${suffix}); restored pristine. Brand theme + prompt overlays disabled.`; + } + const detail = tail3(fail.output); + return `tweakcc failed (${detail || 'no output'}); restored pristine. Brand theme + prompt overlays disabled.`; }; export const ensureTweakccConfig = (tweakDir: string, brandKey?: string | null): boolean => { @@ -181,135 +224,3 @@ export const ensureTweakccConfig = (tweakDir: string, brandKey?: string | null): fs.writeFileSync(configPath, JSON.stringify(brandConfig, null, 2)); return true; }; - -export const runTweakcc = ( - tweakDir: string, - binaryPath: string, - stdio: 'inherit' | 'pipe' = 'inherit' -): TweakccResult => { - const npxCmd = getNpxCommand(); - const env = { - ...process.env, - TWEAKCC_CONFIG_DIR: tweakDir, - TWEAKCC_CC_INSTALLATION_PATH: binaryPath, - } as NodeJS.ProcessEnv; - - if (!commandExists(npxCmd)) { - return { status: 1, stderr: 'npx not found', stdout: '' } as TweakccResult; - } - - const runVersion = (versionSpec: string) => { - const invocation = buildTweakccInvocation(versionSpec, ['--apply']); - const result = spawnSync(invocation.cmd, invocation.args, { stdio: 'pipe', env, encoding: 'utf8' }); - if (stdio === 'inherit') { - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - } - return withTweakccMetadata(result as TweakccResult, versionSpec); - }; - - const primary = runVersion(TWEAKCC_VERSION); - if (!shouldRetryWithLatest(primary, TWEAKCC_VERSION)) { - return primary; - } - - if (stdio === 'inherit') { - writeFallbackNotice(TWEAKCC_VERSION, TWEAKCC_LATEST_SPEC); - } - - const fallback = runVersion(TWEAKCC_LATEST_SPEC); - return withTweakccMetadata(fallback, TWEAKCC_LATEST_SPEC, TWEAKCC_VERSION); -}; - -export const launchTweakccUi = (tweakDir: string, binaryPath: string): TweakccResult => { - const npxCmd = getNpxCommand(); - const env = { - ...process.env, - TWEAKCC_CONFIG_DIR: tweakDir, - TWEAKCC_CC_INSTALLATION_PATH: binaryPath, - } as NodeJS.ProcessEnv; - - if (!commandExists(npxCmd)) { - return { status: 1, stderr: 'npx not found', stdout: '' } as TweakccResult; - } - - const runVersion = (versionSpec: string) => { - const invocation = buildTweakccInvocation(versionSpec, []); - return withTweakccMetadata( - spawnSync(invocation.cmd, invocation.args, { stdio: 'inherit', env, encoding: 'utf8' }) as TweakccResult, - versionSpec - ); - }; - - const primary = runVersion(TWEAKCC_VERSION); - if (!shouldRetryWithLatest(primary, TWEAKCC_VERSION)) { - return primary; - } - - writeFallbackNotice(TWEAKCC_VERSION, TWEAKCC_LATEST_SPEC); - const fallback = runVersion(TWEAKCC_LATEST_SPEC); - return withTweakccMetadata(fallback, TWEAKCC_LATEST_SPEC, TWEAKCC_VERSION); -}; - -// Async version for TUI progress updates -const spawnTweakccAsync = ( - cmd: string, - args: string[], - env: NodeJS.ProcessEnv, - stdio: 'inherit' | 'pipe' -): Promise => { - return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: 'pipe', env }); - let stdout = ''; - let stderr = ''; - child.stdout?.on('data', (d) => { - stdout += d.toString(); - if (stdio === 'inherit') process.stdout.write(d); - }); - child.stderr?.on('data', (d) => { - stderr += d.toString(); - if (stdio === 'inherit') process.stderr.write(d); - }); - child.on('close', (status) => { - resolve({ status, stdout, stderr } as TweakccResult); - }); - child.on('error', (err) => { - resolve({ status: 1, stdout: '', stderr: err.message } as TweakccResult); - }); - }); -}; - -export const runTweakccAsync = async ( - tweakDir: string, - binaryPath: string, - stdio: 'inherit' | 'pipe' = 'inherit' -): Promise => { - const npxCmd = getNpxCommand(); - const env = { - ...process.env, - TWEAKCC_CONFIG_DIR: tweakDir, - TWEAKCC_CC_INSTALLATION_PATH: binaryPath, - } as NodeJS.ProcessEnv; - - if (!commandExists(npxCmd)) { - return { status: 1, stderr: 'npx not found', stdout: '' } as TweakccResult; - } - - const runVersion = async (versionSpec: string) => { - const invocation = buildTweakccInvocation(versionSpec, ['--apply']); - const result = await spawnTweakccAsync(invocation.cmd, invocation.args, env, stdio); - return withTweakccMetadata(result, versionSpec); - }; - - const primary = await runVersion(TWEAKCC_VERSION); - if (!shouldRetryWithLatest(primary, TWEAKCC_VERSION)) { - return primary; - } - - if (stdio === 'inherit') { - writeFallbackNotice(TWEAKCC_VERSION, TWEAKCC_LATEST_SPEC); - } - - const fallback = await runVersion(TWEAKCC_LATEST_SPEC); - return withTweakccMetadata(fallback, TWEAKCC_LATEST_SPEC, TWEAKCC_VERSION); -}; diff --git a/src/core/types.ts b/src/core/types.ts index 9b94e81..482460a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -26,6 +26,27 @@ export interface VariantMeta { */ nativeVersionSource?: 'default' | 'pinned'; nativePlatform?: string; + /** + * True if the binary patcher failed (or corrupted the binary) on the last + * create/update and we restored the pristine binary from cache. The + * variant is functionally equivalent to one created with --no-tweak. + * Field name kept for variant.json compatibility across upgrades. + */ + tweakRolledBack?: boolean; + /** + * Runtime the wrapper script invokes. `'native'` (default) runs the + * Bun-compiled binary at binaryPath; `'node'` runs `node nodeEntryPath`, + * used on macOS when the binary patcher had to fall back to the + * unpack-and-run-via-node path. Persisted so update preserves the choice. + */ + wrapperRuntime?: 'native' | 'node'; + /** + * Path of the patched JS entry module produced by the unpack-and-run-via-node + * path. Set only when wrapperRuntime === 'node'. + */ + nodeEntryPath?: string; + /** Directory holding the unpacked JS modules + node_modules (set with nodeEntryPath). */ + unpackedDir?: string; } export interface VariantEntry { @@ -90,11 +111,22 @@ export interface UpdateVariantOptions { onProgress?: ProgressCallback; } +export interface DoctorBunInfo { + platform: 'macho' | 'elf' | 'pe'; + moduleSize: number; + moduleCount: number; + entryPath?: string; + bunVersionHint?: string; + hasCodeSignature?: boolean; + error?: string; +} + export interface DoctorReportItem { name: string; ok: boolean; binaryPath?: string; wrapperPath: string; + bunInfo?: DoctorBunInfo; } export interface CreateVariantResult { @@ -118,6 +150,4 @@ export interface TweakResult { status: number | null; stderr?: string; stdout?: string; - tweakccSpec?: string; - fallbackFromTweakccSpec?: string; } diff --git a/src/core/variant-builder/VariantBuilder.ts b/src/core/variant-builder/VariantBuilder.ts index 8bdd51f..1abac2a 100644 --- a/src/core/variant-builder/VariantBuilder.ts +++ b/src/core/variant-builder/VariantBuilder.ts @@ -17,7 +17,7 @@ import { PrepareDirectoriesStep } from './steps/PrepareDirectoriesStep.js'; import { InstallNativeStep } from './steps/InstallNativeStep.js'; import { WriteConfigStep } from './steps/WriteConfigStep.js'; import { BrandThemeStep } from './steps/BrandThemeStep.js'; -import { TweakccStep } from './steps/TweakccStep.js'; +import { BinaryPatcherStep } from './steps/BinaryPatcherStep.js'; import { WrapperStep } from './steps/WrapperStep.js'; import { ShellEnvStep } from './steps/ShellEnvStep.js'; import { SkillInstallStep } from './steps/SkillInstallStep.js'; @@ -55,7 +55,7 @@ export class VariantBuilder { new InstallNativeStep(), new WriteConfigStep(), new BrandThemeStep(), // Creates tweakcc/config.json - new TweakccStep(), + new BinaryPatcherStep(), new WrapperStep(), new ShellEnvStep(), new SkillInstallStep(), @@ -82,6 +82,7 @@ export class VariantBuilder { const tweakDir = path.join(variantDir, 'tweakcc'); const wrapperPath = getWrapperPath(resolvedBin, params.name); const nativeDir = path.join(variantDir, 'native'); + const unpackedDir = path.join(variantDir, 'unpacked'); const paths: BuildPaths = { resolvedRoot, @@ -91,6 +92,7 @@ export class VariantBuilder { tweakDir, wrapperPath, nativeDir, + unpackedDir, }; const resolvedClaudeVersion = normalizeClaudeVersion(params.claudeVersion); diff --git a/src/core/variant-builder/VariantUpdater.ts b/src/core/variant-builder/VariantUpdater.ts index f2780a2..4b1d17b 100644 --- a/src/core/variant-builder/VariantUpdater.ts +++ b/src/core/variant-builder/VariantUpdater.ts @@ -17,7 +17,7 @@ import type { ReportFn, UpdateContext, UpdatePaths, UpdatePreferences, UpdateSta import { RebuildUpdateStep } from './update-steps/RebuildUpdateStep.js'; import { InstallNativeUpdateStep } from './update-steps/InstallNativeUpdateStep.js'; import { ModelOverridesStep } from './update-steps/ModelOverridesStep.js'; -import { TweakccUpdateStep } from './update-steps/TweakccUpdateStep.js'; +import { BinaryPatcherUpdateStep } from './update-steps/BinaryPatcherUpdateStep.js'; import { WrapperUpdateStep } from './update-steps/WrapperUpdateStep.js'; import { ConfigUpdateStep } from './update-steps/ConfigUpdateStep.js'; import { ShellEnvUpdateStep } from './update-steps/ShellEnvUpdateStep.js'; @@ -75,7 +75,7 @@ export class VariantUpdater { new RebuildUpdateStep(), new InstallNativeUpdateStep(), new ModelOverridesStep(), - new TweakccUpdateStep(), + new BinaryPatcherUpdateStep(), new WrapperUpdateStep(), new ConfigUpdateStep(), new ShellEnvUpdateStep(), @@ -106,6 +106,7 @@ export class VariantUpdater { resolvedBin: opts.binDir ? (expandTilde(opts.binDir) ?? opts.binDir) : meta.binDir, variantDir, nativeDir: meta.nativeDir || path.join(variantDir, 'native'), + unpackedDir: meta.unpackedDir || path.join(variantDir, 'unpacked'), }; const prefs: UpdatePreferences = { diff --git a/src/core/variant-builder/steps/BinaryPatcherStep.ts b/src/core/variant-builder/steps/BinaryPatcherStep.ts new file mode 100644 index 0000000..0ab5141 --- /dev/null +++ b/src/core/variant-builder/steps/BinaryPatcherStep.ts @@ -0,0 +1,221 @@ +/** + * BinaryPatcherStep - Runs the in-repo binary patcher (theme + prompt + * overlays) and falls back to the Phase 1 rollback flow on failure. + * + * Replaces TweakccStep. Same rollback semantics: on patch failure, + * restore the pristine cached binary, reset .claude.json themeId to + * 'dark', record a rollback note, set state.tweakRolledBack = true, + * and continue so the variant remains usable. The patch implementation + * differs (in-process applyPatches vs out-of-process npx tweakcc) but + * the contract surfaced to the rest of the build pipeline is identical. + */ + +import { ensureOnboardingState } from '../../claude-config.js'; +import { DEFAULT_CLAUDE_NATIVE_CACHE_DIR } from '../../constants.js'; +import { resolveNativeClaudePath, restorePristineBinary } from '../../install.js'; +import { resolveOverlays } from '../../prompt-pack/overlays.js'; +import type { OverlayMap, PromptPackKey } from '../../prompt-pack/types.js'; +import { applyPatches as defaultApplyPatches, type PatchResult } from '../../binary-patcher/index.js'; +import { + UnpackAndPatchError, + unpackAndPatch as defaultUnpackAndPatch, + type UnpackAndPatchResult, +} from '../../binary-patcher/unpack-and-patch.js'; +import { formatRollbackNote, smokeTestBinary, type TweakccPatchFailure } from '../../tweakcc.js'; +import type { TweakccConfig } from '../../../brands/types.js'; +import type { TweakResult } from '../../types.js'; +import type { BuildContext, BuildStep } from '../types.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +const isPromptPackKey = (value: string): value is PromptPackKey => value === 'zai' || value === 'minimax'; + +const loadConfig = (tweakDir: string): TweakccConfig | null => { + const configPath = `${tweakDir}/config.json`; + if (!fs.existsSync(configPath)) return null; + try { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as TweakccConfig; + } catch { + return null; + } +}; + +const patchResultToTweakResult = (result: PatchResult): TweakResult => { + if (result.ok) { + return { status: 0, stderr: '', stdout: '' }; + } + return { status: 1, stderr: `${result.reason}: ${result.detail}`, stdout: '' }; +}; + +const patchResultToFailure = (result: PatchResult & { ok: false }): TweakccPatchFailure => ({ + kind: 'tweakcc-failed', + output: `${result.reason}: ${result.detail}`, +}); + +const performRollback = (ctx: BuildContext, failure: TweakccPatchFailure): void => { + const { params, paths, state } = ctx; + + const restore = restorePristineBinary({ + binaryPath: state.binaryPath, + cacheDir: DEFAULT_CLAUDE_NATIVE_CACHE_DIR, + resolvedVersion: state.nativeResolvedVersion ?? '', + platform: state.nativePlatform ?? '', + }); + + if (!restore.restored) { + const original = formatRollbackNote(failure); + throw new Error( + `${original}\nRollback failed: cached pristine binary is missing at ` + + `${restore.cachePath ?? '///claude'}. ` + + `Re-run with --no-tweak, or clear the variant directory and retry.` + ); + } + + ensureOnboardingState(paths.configDir, { + themeId: 'dark', + forceTheme: true, + skipOnboardingFlag: params.providerKey === 'mirror', + }); + + state.notes.push(formatRollbackNote(failure)); + state.tweakRolledBack = true; +}; + +const resolveOverlaysFor = (providerKey: string, enabled: boolean): OverlayMap | null => { + if (!enabled) return null; + if (!isPromptPackKey(providerKey)) return null; + return resolveOverlays(providerKey); +}; + +export interface BinaryPatcherStepDeps { + applyPatches?: typeof defaultApplyPatches; + unpackAndPatch?: typeof defaultUnpackAndPatch; +} + +export class BinaryPatcherStep implements BuildStep { + name = 'BinaryPatcher'; + + constructor(private deps: BinaryPatcherStepDeps = {}) {} + + execute(ctx: BuildContext): void { + this.run(ctx, 'sync'); + } + + async executeAsync(ctx: BuildContext): Promise { + await ctx.report('Patching Claude Code binary...'); + this.run(ctx, 'async'); + } + + private run(ctx: BuildContext, mode: 'sync' | 'async'): void { + const { params, paths, prefs, state } = ctx; + if (params.noTweak) return; + + if (mode === 'sync') { + ctx.report('Patching Claude Code binary...'); + } + + const config = loadConfig(paths.tweakDir); + if (!config) { + // No brand config to apply (e.g., variant has no brand selected). Nothing to patch. + return; + } + + const overlays = resolveOverlaysFor(params.providerKey, prefs.promptPackEnabled); + const apply = this.deps.applyPatches ?? defaultApplyPatches; + const result = apply({ binaryPath: state.binaryPath, config, overlays }); + + state.tweakResult = patchResultToTweakResult(result); + + if (!result.ok) { + if (mode === 'sync') { + ctx.report('Binary patch failed; restoring pristine binary...'); + } + performRollback(ctx, patchResultToFailure(result)); + return; + } + + // Smoke test the patched binary - same contract as Phase 1 had around + // tweakcc. If ` --version` fails, the patch silently corrupted + // the binary and we roll back. Skipped patches (writeBuf=null) leave the + // pristine binary alone, so smoke is unnecessary in that case. + if (!result.skippedReason) { + const smoke = smokeTestBinary(state.binaryPath); + if (!smoke.ok) { + if (mode === 'sync') { + ctx.report('Patched binary failed smoke test; restoring pristine binary...'); + } + performRollback(ctx, { kind: 'smoke-failed', smoke }); + return; + } + } + + if (result.skippedReason === 'macho-grow-not-supported') { + this.runMacosUnpackFallback(ctx, config, overlays); + return; + } + if (result.missingPromptKeys.length > 0) { + state.notes.push(`Prompt overlay anchor not found for: ${result.missingPromptKeys.join(', ')}`); + } + if (result.codesignSkipped) { + state.notes.push('Binary is unsigned (codesign not available); first launch may show a Gatekeeper prompt.'); + } + } + + /** + * macOS-only fallback for the would-grow case. Extracts the embedded JS + * modules from the SHA256-verified pristine cache copy of the binary, + * patches them as plain JS, installs the runtime deps that Bun externalizes, + * and switches the wrapper to invoke `node ` instead of the + * Bun-compiled binary. + * + * On any failure here we fall through to the Phase 1 rollback (pristine + * binary, native runtime, no theme/overlays) so the variant remains usable. + */ + private runMacosUnpackFallback(ctx: BuildContext, config: TweakccConfig, overlays: OverlayMap | null): void { + const { paths, state } = ctx; + + if (!state.nativeResolvedVersion || !state.nativePlatform) { + state.notes.push( + 'Mach-O patch skipped: theme + prompt patches would grow the binary, and no pristine cache version is recorded for the unpack-and-run-via-node fallback.' + ); + return; + } + + const cachePath = resolveNativeClaudePath( + path.join(DEFAULT_CLAUDE_NATIVE_CACHE_DIR, state.nativeResolvedVersion, state.nativePlatform) + ); + if (!fs.existsSync(cachePath)) { + state.notes.push( + `Mach-O patch skipped: theme + prompt patches would grow the binary, and the pristine cache copy is missing at ${cachePath} (cannot fall back to the unpack-and-run-via-node path).` + ); + return; + } + + ctx.report('Mach-O patch would grow binary; unpacking and patching JS for the node runtime...'); + + const unpack = this.deps.unpackAndPatch ?? defaultUnpackAndPatch; + let unpackResult: UnpackAndPatchResult; + try { + unpackResult = unpack({ + pristineBinaryPath: cachePath, + unpackedDir: paths.unpackedDir, + config, + overlays, + }); + } catch (err) { + const detail = err instanceof UnpackAndPatchError ? err.message : (err as Error).message; + ctx.report('Unpack-and-patch failed; restoring pristine binary...'); + performRollback(ctx, { kind: 'tweakcc-failed', output: detail }); + return; + } + + state.wrapperRuntime = 'node'; + state.nodeEntryPath = unpackResult.entryPath; + state.notes.push( + 'macOS variant: running unpacked JS via node (Mach-O segment shifting not implemented). Brand theme + prompt overlays applied to the extracted cli.js.' + ); + if (unpackResult.patch.promptMissing.length > 0) { + state.notes.push(`Prompt overlay anchor not found for: ${unpackResult.patch.promptMissing.join(', ')}`); + } + } +} diff --git a/src/core/variant-builder/steps/FinalizeStep.ts b/src/core/variant-builder/steps/FinalizeStep.ts index 066a687..94dbc19 100644 --- a/src/core/variant-builder/steps/FinalizeStep.ts +++ b/src/core/variant-builder/steps/FinalizeStep.ts @@ -44,6 +44,16 @@ export class FinalizeStep implements BuildStep { meta.nativeVersion = prefs.resolvedClaudeVersion; meta.nativeVersionSource = meta.nativeVersion === DEFAULT_CLAUDE_VERSION ? 'default' : 'pinned'; meta.nativePlatform = state.nativePlatform; + if (state.tweakRolledBack) { + meta.tweakRolledBack = true; + } + if (state.wrapperRuntime) { + meta.wrapperRuntime = state.wrapperRuntime; + } + if (state.nodeEntryPath) { + meta.nodeEntryPath = state.nodeEntryPath; + meta.unpackedDir = paths.unpackedDir; + } writeJson(path.join(paths.variantDir, 'variant.json'), meta); diff --git a/src/core/variant-builder/steps/TweakccStep.ts b/src/core/variant-builder/steps/TweakccStep.ts deleted file mode 100644 index ba5d8cf..0000000 --- a/src/core/variant-builder/steps/TweakccStep.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * TweakccStep - Runs tweakcc patches and applies prompt packs - */ - -import { applyPromptPack } from '../../prompt-pack.js'; -import { getTweakccFallbackNote, runTweakcc, runTweakccAsync } from '../../tweakcc.js'; -import { formatTweakccFailure } from '../../errors.js'; -import type { BuildContext, BuildStep } from '../types.js'; - -export class TweakccStep implements BuildStep { - name = 'Tweakcc'; - - execute(ctx: BuildContext): void { - const { params, paths, prefs, state } = ctx; - - if (params.noTweak) { - return; - } - - ctx.report('Running tweakcc patches...'); - state.tweakResult = runTweakcc(paths.tweakDir, state.binaryPath, prefs.commandStdio); - const fallbackNote = getTweakccFallbackNote(state.tweakResult); - if (fallbackNote && !state.notes.includes(fallbackNote)) { - state.notes.push(fallbackNote); - } - - if (state.tweakResult.status !== 0) { - const output = `${state.tweakResult.stderr ?? ''}\n${state.tweakResult.stdout ?? ''}`.trim(); - throw new Error(formatTweakccFailure(output)); - } - - let shouldReapply = false; - - if (prefs.promptPackEnabled) { - ctx.report('Applying prompt pack...'); - const packResult = applyPromptPack(paths.tweakDir, params.providerKey); - - if (packResult.changed) { - state.notes.push(`Prompt pack applied (${packResult.updated.join(', ')})`); - shouldReapply = true; - } - } - - if (shouldReapply) { - ctx.report('Re-applying tweakcc...'); - const reapply = runTweakcc(paths.tweakDir, state.binaryPath, prefs.commandStdio); - state.tweakResult = reapply; - const reapplyFallbackNote = getTweakccFallbackNote(reapply); - if (reapplyFallbackNote && !state.notes.includes(reapplyFallbackNote)) { - state.notes.push(reapplyFallbackNote); - } - - if (reapply.status !== 0) { - const output = `${reapply.stderr ?? ''}\n${reapply.stdout ?? ''}`.trim(); - throw new Error(formatTweakccFailure(output)); - } - } - } - - async executeAsync(ctx: BuildContext): Promise { - const { params, paths, prefs, state } = ctx; - - if (params.noTweak) { - return; - } - - await ctx.report('Running tweakcc patches...'); - state.tweakResult = await runTweakccAsync(paths.tweakDir, state.binaryPath, prefs.commandStdio); - const fallbackNote = getTweakccFallbackNote(state.tweakResult); - if (fallbackNote && !state.notes.includes(fallbackNote)) { - state.notes.push(fallbackNote); - } - - if (state.tweakResult.status !== 0) { - const output = `${state.tweakResult.stderr ?? ''}\n${state.tweakResult.stdout ?? ''}`.trim(); - throw new Error(formatTweakccFailure(output)); - } - - let shouldReapply = false; - - if (prefs.promptPackEnabled) { - await ctx.report('Applying prompt pack...'); - const packResult = applyPromptPack(paths.tweakDir, params.providerKey); - - if (packResult.changed) { - state.notes.push(`Prompt pack applied (${packResult.updated.join(', ')})`); - shouldReapply = true; - } - } - - if (shouldReapply) { - await ctx.report('Re-applying tweakcc...'); - const reapply = await runTweakccAsync(paths.tweakDir, state.binaryPath, prefs.commandStdio); - state.tweakResult = reapply; - const reapplyFallbackNote = getTweakccFallbackNote(reapply); - if (reapplyFallbackNote && !state.notes.includes(reapplyFallbackNote)) { - state.notes.push(reapplyFallbackNote); - } - - if (reapply.status !== 0) { - const output = `${reapply.stderr ?? ''}\n${reapply.stdout ?? ''}`.trim(); - throw new Error(formatTweakccFailure(output)); - } - } - } -} diff --git a/src/core/variant-builder/steps/WrapperStep.ts b/src/core/variant-builder/steps/WrapperStep.ts index cd6e250..f8fa9a8 100644 --- a/src/core/variant-builder/steps/WrapperStep.ts +++ b/src/core/variant-builder/steps/WrapperStep.ts @@ -6,18 +6,27 @@ import { writeWrapper } from '../../wrapper.js'; import { ensureWindowsUserPath } from '../../windows-path.js'; import type { BuildContext, BuildStep } from '../types.js'; +const resolveTarget = (ctx: BuildContext): { binary: string; runtime: 'native' | 'node' } => { + if (ctx.state.wrapperRuntime === 'node' && ctx.state.nodeEntryPath) { + return { binary: ctx.state.nodeEntryPath, runtime: 'node' }; + } + return { binary: ctx.state.binaryPath, runtime: 'native' }; +}; + export class WrapperStep implements BuildStep { name = 'Wrapper'; execute(ctx: BuildContext): void { ctx.report('Writing CLI wrapper...'); - writeWrapper(ctx.paths.wrapperPath, ctx.paths.configDir, ctx.state.binaryPath, 'native'); + const { binary, runtime } = resolveTarget(ctx); + writeWrapper(ctx.paths.wrapperPath, ctx.paths.configDir, binary, runtime); this.ensureWindowsPath(ctx); } async executeAsync(ctx: BuildContext): Promise { await ctx.report('Writing CLI wrapper...'); - writeWrapper(ctx.paths.wrapperPath, ctx.paths.configDir, ctx.state.binaryPath, 'native'); + const { binary, runtime } = resolveTarget(ctx); + writeWrapper(ctx.paths.wrapperPath, ctx.paths.configDir, binary, runtime); this.ensureWindowsPath(ctx); } diff --git a/src/core/variant-builder/steps/index.ts b/src/core/variant-builder/steps/index.ts index f3f7775..f667ef8 100644 --- a/src/core/variant-builder/steps/index.ts +++ b/src/core/variant-builder/steps/index.ts @@ -6,7 +6,7 @@ export { PrepareDirectoriesStep } from './PrepareDirectoriesStep.js'; export { InstallNativeStep } from './InstallNativeStep.js'; export { WriteConfigStep } from './WriteConfigStep.js'; export { BrandThemeStep } from './BrandThemeStep.js'; -export { TweakccStep } from './TweakccStep.js'; +export { BinaryPatcherStep } from './BinaryPatcherStep.js'; export { WrapperStep } from './WrapperStep.js'; export { ShellEnvStep } from './ShellEnvStep.js'; export { SkillInstallStep } from './SkillInstallStep.js'; diff --git a/src/core/variant-builder/types.ts b/src/core/variant-builder/types.ts index a4c2c75..e8e0b56 100644 --- a/src/core/variant-builder/types.ts +++ b/src/core/variant-builder/types.ts @@ -7,6 +7,7 @@ import type { ProviderTemplate, ProviderEnv } from '../../providers/index.js'; import type { CreateVariantParams, TweakResult, UpdateVariantOptions, VariantMeta } from '../types.js'; +import type { WrapperRuntime } from '../wrapper.js'; /** * Progress reporter - can be sync or async @@ -24,6 +25,8 @@ export interface BuildPaths { tweakDir: string; wrapperPath: string; nativeDir: string; + /** Target dir for the macOS unpack-and-run-via-node path. */ + unpackedDir: string; } /** @@ -53,6 +56,19 @@ export interface BuildState { env?: ProviderEnv; resolvedApiKey?: string; meta?: VariantMeta; + /** Set by TweakccStep when patch failed and pristine was restored. */ + tweakRolledBack?: boolean; + /** + * Runtime the wrapper script should invoke. `'native'` means exec the + * Bun-compiled binary at state.binaryPath; `'node'` means exec + * `node state.nodeEntryPath` (used on macOS when the binary patcher had to + * fall back to unpack-and-run-via-node because patches would have grown + * the Mach-O __BUN section). Persisted into VariantMeta so the update + * flow stays on the same runtime. + */ + wrapperRuntime?: WrapperRuntime; + /** Absolute entry-module path for the `node` runtime. */ + nodeEntryPath?: string; } /** @@ -115,6 +131,8 @@ export interface UpdatePaths { resolvedBin: string | undefined; variantDir: string; nativeDir: string; + /** Target dir for the macOS unpack-and-run-via-node path. */ + unpackedDir: string; } /** @@ -138,6 +156,16 @@ export interface UpdateState { tweakResult: TweakResult | null; brandKey: string | null; savedTweakccConfig?: string; + /** Set by TweakccUpdateStep when patch failed and pristine was restored. */ + tweakRolledBack?: boolean; + /** Resolved Claude Code version (e.g. "2.1.119") populated by InstallNativeUpdateStep. */ + nativeResolvedVersion?: string; + /** Platform key (e.g. "darwin-arm64") populated by InstallNativeUpdateStep. */ + nativePlatform?: string; + /** Runtime the wrapper should invoke after this update completes. */ + wrapperRuntime?: WrapperRuntime; + /** Absolute entry-module path for the `node` runtime. */ + nodeEntryPath?: string; } /** diff --git a/src/core/variant-builder/update-steps/BinaryPatcherUpdateStep.ts b/src/core/variant-builder/update-steps/BinaryPatcherUpdateStep.ts new file mode 100644 index 0000000..d467b67 --- /dev/null +++ b/src/core/variant-builder/update-steps/BinaryPatcherUpdateStep.ts @@ -0,0 +1,235 @@ +/** + * BinaryPatcherUpdateStep - Update-flow counterpart to BinaryPatcherStep. + * + * Mirrors the create-side step's contract: invoke the in-repo patcher, + * fall back to Phase 1 rollback on failure. Keeps the settingsOnly + * guard from TweakccUpdateStep that refuses rollback when the caller + * deliberately skipped the binary reinstall (no nativeResolvedVersion / + * nativePlatform = no cache key to restore from). + */ + +import { resolveBrandKey } from '../../../brands/index.js'; +import { ensureOnboardingState } from '../../claude-config.js'; +import { DEFAULT_CLAUDE_NATIVE_CACHE_DIR } from '../../constants.js'; +import { ensureDir } from '../../fs.js'; +import { resolveNativeClaudePath, restorePristineBinary } from '../../install.js'; +import { resolveOverlays } from '../../prompt-pack/overlays.js'; +import type { OverlayMap, PromptPackKey } from '../../prompt-pack/types.js'; +import { applyPatches as defaultApplyPatches, type PatchResult } from '../../binary-patcher/index.js'; +import { + UnpackAndPatchError, + unpackAndPatch as defaultUnpackAndPatch, + type UnpackAndPatchResult, +} from '../../binary-patcher/unpack-and-patch.js'; +import { ensureTweakccConfig, formatRollbackNote, smokeTestBinary, type TweakccPatchFailure } from '../../tweakcc.js'; +import type { TweakccConfig } from '../../../brands/types.js'; +import type { TweakResult } from '../../types.js'; +import type { UpdateContext, UpdateStep } from '../types.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +const isPromptPackKey = (value: string): value is PromptPackKey => value === 'zai' || value === 'minimax'; + +const loadConfig = (tweakDir: string): TweakccConfig | null => { + const configPath = `${tweakDir}/config.json`; + if (!fs.existsSync(configPath)) return null; + try { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as TweakccConfig; + } catch { + return null; + } +}; + +const patchResultToTweakResult = (result: PatchResult): TweakResult => { + if (result.ok) { + return { status: 0, stderr: '', stdout: '' }; + } + return { status: 1, stderr: `${result.reason}: ${result.detail}`, stdout: '' }; +}; + +const patchResultToFailure = (result: PatchResult & { ok: false }): TweakccPatchFailure => ({ + kind: 'tweakcc-failed', + output: `${result.reason}: ${result.detail}`, +}); + +const performRollback = (ctx: UpdateContext, failure: TweakccPatchFailure): void => { + const { meta, state } = ctx; + + if (!state.nativeResolvedVersion || !state.nativePlatform) { + // settingsOnly skipped the binary reinstall; we have no cache key to + // restore from. Surface the original error rather than leaving a + // half-restored state. + throw new Error( + `${formatRollbackNote(failure)}\nCannot rollback in settings-only update; re-run the full update or recreate the variant with --no-tweak.` + ); + } + + const restore = restorePristineBinary({ + binaryPath: meta.binaryPath, + cacheDir: DEFAULT_CLAUDE_NATIVE_CACHE_DIR, + resolvedVersion: state.nativeResolvedVersion, + platform: state.nativePlatform, + }); + + if (!restore.restored) { + const original = formatRollbackNote(failure); + throw new Error( + `${original}\nRollback failed: cached pristine binary is missing at ` + + `${restore.cachePath ?? '///claude'}. ` + + `Re-run with --no-tweak, or recreate the variant.` + ); + } + + ensureOnboardingState(meta.configDir, { + themeId: 'dark', + forceTheme: true, + skipOnboardingFlag: meta.provider === 'mirror', + }); + + state.notes.push(formatRollbackNote(failure)); + state.tweakRolledBack = true; +}; + +const resolveOverlaysFor = (providerKey: string, enabled: boolean): OverlayMap | null => { + if (!enabled) return null; + if (!isPromptPackKey(providerKey)) return null; + return resolveOverlays(providerKey); +}; + +export interface BinaryPatcherUpdateStepDeps { + applyPatches?: typeof defaultApplyPatches; + unpackAndPatch?: typeof defaultUnpackAndPatch; +} + +export class BinaryPatcherUpdateStep implements UpdateStep { + name = 'BinaryPatcher'; + + constructor(private deps: BinaryPatcherUpdateStepDeps = {}) {} + + execute(ctx: UpdateContext): void { + this.run(ctx, 'sync'); + } + + async executeAsync(ctx: UpdateContext): Promise { + await ctx.report('Patching Claude Code binary...'); + this.run(ctx, 'async'); + } + + private run(ctx: UpdateContext, mode: 'sync' | 'async'): void { + const { opts, meta, prefs, state } = ctx; + if (opts.noTweak) return; + + if (mode === 'sync') { + ctx.report('Patching Claude Code binary...'); + } + + ensureDir(meta.tweakDir); + + if (opts.brand !== undefined) { + state.brandKey = resolveBrandKey(meta.provider, opts.brand); + meta.brand = state.brandKey ?? undefined; + } + + ensureTweakccConfig(meta.tweakDir, state.brandKey); + + const config = loadConfig(meta.tweakDir); + if (!config) return; + + const overlays = resolveOverlaysFor(meta.provider, prefs.promptPackEnabled); + const apply = this.deps.applyPatches ?? defaultApplyPatches; + const result = apply({ binaryPath: meta.binaryPath, config, overlays }); + + state.tweakResult = patchResultToTweakResult(result); + + if (!result.ok) { + if (mode === 'sync') { + ctx.report('Binary patch failed; restoring pristine binary...'); + } + performRollback(ctx, patchResultToFailure(result)); + return; + } + + if (result.skippedReason === 'macho-grow-not-supported') { + this.runMacosUnpackFallback(ctx, config, overlays, mode); + return; + } + + const smoke = smokeTestBinary(meta.binaryPath); + if (!smoke.ok) { + if (mode === 'sync') { + ctx.report('Patched binary failed smoke test; restoring pristine binary...'); + } + performRollback(ctx, { kind: 'smoke-failed', smoke }); + return; + } + + if (result.missingPromptKeys.length > 0) { + state.notes.push(`Prompt overlay anchor not found for: ${result.missingPromptKeys.join(', ')}`); + } + if (result.codesignSkipped) { + state.notes.push('Binary is unsigned (codesign not available); first launch may show a Gatekeeper prompt.'); + } + } + + /** + * Mirror of BinaryPatcherStep.runMacosUnpackFallback for the update path. + * On success, sets state.wrapperRuntime + state.nodeEntryPath so + * WrapperUpdateStep rewrites the wrapper to invoke `node `. + */ + private runMacosUnpackFallback( + ctx: UpdateContext, + config: TweakccConfig, + overlays: OverlayMap | null, + mode: 'sync' | 'async' + ): void { + const { paths, state } = ctx; + + if (!state.nativeResolvedVersion || !state.nativePlatform) { + state.notes.push( + 'Mach-O patch skipped: theme + prompt patches would grow the binary, and no pristine cache version is recorded for the unpack-and-run-via-node fallback.' + ); + return; + } + + const cachePath = resolveNativeClaudePath( + path.join(DEFAULT_CLAUDE_NATIVE_CACHE_DIR, state.nativeResolvedVersion, state.nativePlatform) + ); + if (!fs.existsSync(cachePath)) { + state.notes.push( + `Mach-O patch skipped: theme + prompt patches would grow the binary, and the pristine cache copy is missing at ${cachePath} (cannot fall back to the unpack-and-run-via-node path).` + ); + return; + } + + if (mode === 'sync') { + ctx.report('Mach-O patch would grow binary; unpacking and patching JS for the node runtime...'); + } + + const unpack = this.deps.unpackAndPatch ?? defaultUnpackAndPatch; + let unpackResult: UnpackAndPatchResult; + try { + unpackResult = unpack({ + pristineBinaryPath: cachePath, + unpackedDir: paths.unpackedDir, + config, + overlays, + }); + } catch (err) { + const detail = err instanceof UnpackAndPatchError ? err.message : (err as Error).message; + if (mode === 'sync') { + ctx.report('Unpack-and-patch failed; restoring pristine binary...'); + } + performRollback(ctx, { kind: 'tweakcc-failed', output: detail }); + return; + } + + state.wrapperRuntime = 'node'; + state.nodeEntryPath = unpackResult.entryPath; + state.notes.push( + 'macOS variant: running unpacked JS via node (Mach-O segment shifting not implemented). Brand theme + prompt overlays applied to the extracted cli.js.' + ); + if (unpackResult.patch.promptMissing.length > 0) { + state.notes.push(`Prompt overlay anchor not found for: ${unpackResult.patch.promptMissing.join(', ')}`); + } + } +} diff --git a/src/core/variant-builder/update-steps/FinalizeUpdateStep.ts b/src/core/variant-builder/update-steps/FinalizeUpdateStep.ts index aaea030..5aa001c 100644 --- a/src/core/variant-builder/update-steps/FinalizeUpdateStep.ts +++ b/src/core/variant-builder/update-steps/FinalizeUpdateStep.ts @@ -21,7 +21,7 @@ export class FinalizeUpdateStep implements UpdateStep { } private finalize(ctx: UpdateContext): void { - const { meta, paths, prefs } = ctx; + const { meta, paths, prefs, state } = ctx; meta.updatedAt = new Date().toISOString(); meta.promptPack = prefs.promptPackPreference; @@ -31,6 +31,13 @@ export class FinalizeUpdateStep implements UpdateStep { // Remove deprecated promptPackMode if present delete meta.promptPackMode; + // The wrapper-runtime fields persist whatever this update resolved. If the + // update didn't run the binary patcher (settingsOnly), keep the existing + // values from variant.json so the wrapper stays on the same runtime. + const resolvedRuntime = state.wrapperRuntime ?? meta.wrapperRuntime; + const resolvedNodeEntry = state.nodeEntryPath ?? meta.nodeEntryPath; + const resolvedUnpackedDir = state.wrapperRuntime === 'node' ? paths.unpackedDir : (meta.unpackedDir ?? undefined); + // Existing variants may carry legacy metadata fields from older cc-mirror versions. // Write a normalized variant.json so the file reflects our current native-only schema. const sanitized: VariantMeta = { @@ -52,6 +59,12 @@ export class FinalizeUpdateStep implements UpdateStep { nativeVersion: meta.nativeVersion, nativeVersionSource: meta.nativeVersionSource, nativePlatform: meta.nativePlatform, + // Reflect this update's outcome. Drop the flag on successful re-tweak; + // set it when the update rolled back. + tweakRolledBack: state.tweakRolledBack ? true : undefined, + wrapperRuntime: resolvedRuntime, + nodeEntryPath: resolvedNodeEntry, + unpackedDir: resolvedUnpackedDir, }; ctx.meta = sanitized; diff --git a/src/core/variant-builder/update-steps/InstallNativeUpdateStep.ts b/src/core/variant-builder/update-steps/InstallNativeUpdateStep.ts index a00e622..5e32159 100644 --- a/src/core/variant-builder/update-steps/InstallNativeUpdateStep.ts +++ b/src/core/variant-builder/update-steps/InstallNativeUpdateStep.ts @@ -18,7 +18,7 @@ export class InstallNativeUpdateStep implements UpdateStep { async executeAsync(ctx: UpdateContext): Promise { if (ctx.opts.settingsOnly) return; - const { meta, paths, prefs } = ctx; + const { meta, paths, prefs, state } = ctx; await ctx.report(`Installing Claude Code (native) ${prefs.resolvedClaudeVersion}...`); ensureDir(paths.nativeDir); @@ -41,5 +41,10 @@ export class InstallNativeUpdateStep implements UpdateStep { } meta.nativePlatform = install.platform; meta.claudeOrig = `native:${install.resolvedVersion}`; + + // Thread resolved version + platform to downstream steps (TweakccUpdateStep + // needs them to locate the pristine binary in cache for rollback). + state.nativeResolvedVersion = install.resolvedVersion; + state.nativePlatform = install.platform; } } diff --git a/src/core/variant-builder/update-steps/TweakccUpdateStep.ts b/src/core/variant-builder/update-steps/TweakccUpdateStep.ts deleted file mode 100644 index 711aa92..0000000 --- a/src/core/variant-builder/update-steps/TweakccUpdateStep.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * TweakccUpdateStep - Runs tweakcc patches with prompt pack support - */ - -import { resolveBrandKey } from '../../../brands/index.js'; -import { ensureDir } from '../../fs.js'; -import { applyPromptPack } from '../../prompt-pack.js'; -import { ensureTweakccConfig, getTweakccFallbackNote, runTweakcc, runTweakccAsync } from '../../tweakcc.js'; -import { formatTweakccFailure } from '../../errors.js'; -import type { UpdateContext, UpdateStep } from '../types.js'; - -export class TweakccUpdateStep implements UpdateStep { - name = 'Tweakcc'; - - execute(ctx: UpdateContext): void { - if (ctx.opts.noTweak) return; - ctx.report('Running tweakcc patches...'); - this.runTweakcc(ctx, false); - } - - async executeAsync(ctx: UpdateContext): Promise { - if (ctx.opts.noTweak) return; - await ctx.report('Running tweakcc patches...'); - await this.runTweakcc(ctx, true); - } - - private async runTweakcc(ctx: UpdateContext, isAsync: boolean): Promise { - const { opts, meta, prefs, state } = ctx; - - ensureDir(meta.tweakDir); - - // Handle brand override - if (opts.brand !== undefined) { - state.brandKey = resolveBrandKey(meta.provider, opts.brand); - meta.brand = state.brandKey ?? undefined; - } - - ensureTweakccConfig(meta.tweakDir, state.brandKey); - - // Run tweakcc - const tweakResult = isAsync - ? await runTweakccAsync(meta.tweakDir, meta.binaryPath, prefs.commandStdio) - : runTweakcc(meta.tweakDir, meta.binaryPath, prefs.commandStdio); - - state.tweakResult = tweakResult; - const fallbackNote = getTweakccFallbackNote(tweakResult); - if (fallbackNote && !state.notes.includes(fallbackNote)) { - state.notes.push(fallbackNote); - } - - if (tweakResult.status !== 0) { - const output = `${tweakResult.stderr ?? ''}\n${tweakResult.stdout ?? ''}`.trim(); - throw new Error(formatTweakccFailure(output)); - } - - let shouldReapply = false; - - // Apply prompt pack if enabled - if (prefs.promptPackEnabled) { - if (isAsync) { - await ctx.report('Applying prompt pack...'); - } else { - ctx.report('Applying prompt pack...'); - } - - const packResult = applyPromptPack(meta.tweakDir, meta.provider); - if (packResult.changed) { - state.notes.push(`Prompt pack applied (${packResult.updated.join(', ')})`); - shouldReapply = true; - } - } - - if (shouldReapply) { - if (isAsync) { - await ctx.report('Re-applying tweakcc...'); - } else { - ctx.report('Re-applying tweakcc...'); - } - - const reapply = isAsync - ? await runTweakccAsync(meta.tweakDir, meta.binaryPath, prefs.commandStdio) - : runTweakcc(meta.tweakDir, meta.binaryPath, prefs.commandStdio); - - state.tweakResult = reapply; - const reapplyFallbackNote = getTweakccFallbackNote(reapply); - if (reapplyFallbackNote && !state.notes.includes(reapplyFallbackNote)) { - state.notes.push(reapplyFallbackNote); - } - - if (reapply.status !== 0) { - const output = `${reapply.stderr ?? ''}\n${reapply.stdout ?? ''}`.trim(); - throw new Error(formatTweakccFailure(output)); - } - } - } -} diff --git a/src/core/variant-builder/update-steps/WrapperUpdateStep.ts b/src/core/variant-builder/update-steps/WrapperUpdateStep.ts index 960f9ce..291c414 100644 --- a/src/core/variant-builder/update-steps/WrapperUpdateStep.ts +++ b/src/core/variant-builder/update-steps/WrapperUpdateStep.ts @@ -24,14 +24,20 @@ export class WrapperUpdateStep implements UpdateStep { } private writeWrapper(ctx: UpdateContext): void { - const { name, opts, meta } = ctx; + const { name, opts, meta, state } = ctx; const resolvedBin = opts.binDir ? (expandTilde(opts.binDir) ?? opts.binDir) : meta.binDir; if (resolvedBin) { ensureDir(resolvedBin); const wrapperPath = getWrapperPath(resolvedBin, name); - writeWrapper(wrapperPath, meta.configDir, meta.binaryPath, 'native'); + // Prefer the freshly-resolved runtime from this update; fall back to the + // persisted choice in variant.json (so settings-only updates keep the + // previously chosen runtime). + const runtime = state.wrapperRuntime ?? meta.wrapperRuntime ?? 'native'; + const target = + runtime === 'node' ? (state.nodeEntryPath ?? meta.nodeEntryPath ?? meta.binaryPath) : meta.binaryPath; + writeWrapper(wrapperPath, meta.configDir, target, runtime); meta.binDir = resolvedBin; const pathResult = ensureWindowsUserPath(resolvedBin); if (pathResult.status === 'updated') { diff --git a/src/core/variant-builder/update-steps/index.ts b/src/core/variant-builder/update-steps/index.ts index 5ad7e8d..1a2d587 100644 --- a/src/core/variant-builder/update-steps/index.ts +++ b/src/core/variant-builder/update-steps/index.ts @@ -4,7 +4,7 @@ export { InstallNativeUpdateStep } from './InstallNativeUpdateStep.js'; export { ModelOverridesStep } from './ModelOverridesStep.js'; -export { TweakccUpdateStep } from './TweakccUpdateStep.js'; +export { BinaryPatcherUpdateStep } from './BinaryPatcherUpdateStep.js'; export { WrapperUpdateStep } from './WrapperUpdateStep.js'; export { ConfigUpdateStep } from './ConfigUpdateStep.js'; export { ShellEnvUpdateStep } from './ShellEnvUpdateStep.js'; diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 07d3dd5..08a70ac 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -108,7 +108,6 @@ export interface CoreModule { onProgress?: (step: string) => void; } ) => UpdateVariantResult; - tweakVariant: (rootDir: string, name: string) => void; removeVariant: (rootDir: string, name: string) => void; doctor: (rootDir: string, binDir: string) => DoctorReportItem[]; createVariantAsync?: (params: { diff --git a/test/core/binary-patcher/applyPatches.test.ts b/test/core/binary-patcher/applyPatches.test.ts new file mode 100644 index 0000000..68778b0 --- /dev/null +++ b/test/core/binary-patcher/applyPatches.test.ts @@ -0,0 +1,197 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { applyPatches } from '../../../src/core/binary-patcher/index.js'; +import { parseBunBinary } from '../../../src/core/bun-extract.js'; +import { OVERLAY_MARKERS } from '../../../src/core/binary-patcher/prompts.js'; +import { buildBunFixture } from '../../helpers/bun-fixture.js'; +import type { TweakccConfig, Theme } from '../../../src/brands/types.js'; + +const themes: Theme[] = [ + { id: 'dark', name: 'Dark mode', colors: { bashBorder: '#fff' } }, + { id: 'zai-gold', name: 'Z.ai gold', colors: { bashBorder: '#daa' } }, +]; + +const buildEntryJs = (): string => + [ + 'function getNames(){return{"dark":"Dark mode","light":"Light mode"}}', + 'const themeOptions=[{label:"Dark mode",value:"dark"},{label:"Light mode",value:"light"}];', + 'function pickTheme(A){switch(A){case"light":return LX9;case"dark":return CX9;default:return CX9}}', + 'let WEBFETCH=`Fetches and processes URLs.\n\n - For GitHub URLs, prefer using the gh CLI via Bash instead (e.g., gh pr view, gh issue view, gh api).`;', + ].join('\n'); + +const buildConfig = (): TweakccConfig => ({ + ccVersion: '2.1.98', + ccInstallationPath: null, + lastModified: '2026-04-25T00:00:00Z', + changesApplied: false, + hidePiebaldAnnouncement: true, + settings: { + themes, + thinkingVerbs: { format: '...', verbs: [] }, + thinkingStyle: { reverseMirror: false, updateInterval: 100, phases: [] }, + userMessageDisplay: { + format: '> {}', + styling: [], + foregroundColor: 'default', + backgroundColor: null, + borderStyle: 'round', + borderColor: 'default', + paddingX: 1, + paddingY: 0, + fitBoxToContent: true, + }, + inputBox: { removeBorder: false }, + misc: { + showTweakccVersion: false, + showPatchesApplied: false, + expandThinkingBlocks: false, + enableConversationTitle: false, + hideStartupBanner: false, + hideCtrlGToEdit: false, + hideStartupClawd: false, + increaseFileReadLimit: false, + suppressLineNumbers: false, + suppressRateLimitOptions: false, + mcpConnectionNonBlocking: false, + mcpServerBatchSize: null, + statuslineThrottleMs: null, + statuslineUseFixedInterval: false, + tableFormat: 'default', + enableSwarmMode: false, + enableSessionMemory: false, + enableRememberSkill: false, + tokenCountRounding: null, + autoAcceptPlanMode: false, + allowBypassPermissionsInSudo: null, + suppressNativeInstallerWarning: false, + filterScrollEscapeSequences: false, + }, + claudeMdAltNames: null, + }, +}); + +const writeFixture = (platform: 'elf' | 'macho' | 'pe', entryJs: string): string => { + const fix = buildBunFixture({ + platform, + moduleStructSize: 52, + modules: [ + { name: 'src/header.js', content: 'function header(){}' }, + { name: 'src/cli.js', content: entryJs }, + { name: 'src/footer.js', content: 'function footer(){}' }, + ], + entryPointId: 1, + }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-binary-patcher-')); + const file = path.join(dir, 'claude'); + fs.writeFileSync(file, fix.buf); + return file; +}; + +const readEntryJs = (binaryPath: string): string => { + const buf = fs.readFileSync(binaryPath); + const info = parseBunBinary(buf); + const entry = info.modules[info.entryPointId]; + return buf.subarray(info.dataStart + entry.contOff, info.dataStart + entry.contOff + entry.contLen).toString('utf8'); +}; + +// ELF and PE support resize, so the patches actually land. +for (const platform of ['elf', 'pe'] as const) { + test(`applyPatches end-to-end on ${platform}: theme + prompts both applied`, () => { + const binaryPath = writeFixture(platform, buildEntryJs()); + const result = applyPatches({ + binaryPath, + config: buildConfig(), + overlays: { webfetch: 'Use zai-cli read instead.' }, + }); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual(result.missingPromptKeys, []); + assert.equal(result.resigned, false); + assert.equal(result.skippedReason, undefined); + + const newJs = readEntryJs(binaryPath); + assert.match(newJs, /case"zai-gold":return\{"bashBorder":"#daa"\}/); + assert.match(newJs, /\{"label":"Z\.ai gold","value":"zai-gold"\}/); + assert.ok(newJs.includes(OVERLAY_MARKERS.start)); + assert.ok(newJs.includes('Use zai-cli read instead.')); + assert.ok(newJs.includes(OVERLAY_MARKERS.end)); + + fs.rmSync(path.dirname(binaryPath), { recursive: true, force: true }); + }); + + test(`applyPatches on ${platform} returns anchor-not-found when theme switch is gone`, () => { + const broken = buildEntryJs().replace(/function pickTheme[^}]+\}\}/, '/* removed */'); + const binaryPath = writeFixture(platform, broken); + const result = applyPatches({ + binaryPath, + config: buildConfig(), + overlays: null, + }); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.reason, 'anchor-not-found'); + fs.rmSync(path.dirname(binaryPath), { recursive: true, force: true }); + }); +} + +// Mach-O has its own contract: same-size only, so any growing patch (theme +// rewrite always grows because the original switch references identifiers and +// our rewrite inlines colors) is skipped without modifying the binary. +test('applyPatches end-to-end on macho: skips when patches would grow the entry JS', () => { + const binaryPath = writeFixture('macho', buildEntryJs()); + const originalBytes = fs.readFileSync(binaryPath).length; + const result = applyPatches({ + binaryPath, + config: buildConfig(), + overlays: { webfetch: 'Use zai-cli read instead.' }, + }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.skippedReason, 'macho-grow-not-supported'); + assert.equal(fs.readFileSync(binaryPath).length, originalBytes, 'binary should be unchanged'); + fs.rmSync(path.dirname(binaryPath), { recursive: true, force: true }); +}); + +test('applyPatches on macho returns anchor-not-found when theme switch is gone (no skip)', () => { + const broken = buildEntryJs().replace(/function pickTheme[^}]+\}\}/, '/* removed */'); + const binaryPath = writeFixture('macho', broken); + const result = applyPatches({ + binaryPath, + config: buildConfig(), + overlays: null, + }); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.reason, 'anchor-not-found'); + fs.rmSync(path.dirname(binaryPath), { recursive: true, force: true }); +}); + +test('applyPatches records prompt keys whose anchor is missing without aborting', () => { + const binaryPath = writeFixture('elf', buildEntryJs()); + const result = applyPatches({ + binaryPath, + config: buildConfig(), + // 'main' has no anchor in cc-mirror; should be in missingPromptKeys. + overlays: { webfetch: 'web', main: 'main' }, + }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual(result.missingPromptKeys, ['main']); + fs.rmSync(path.dirname(binaryPath), { recursive: true, force: true }); +}); + +test('applyPatches returns io-error when binary path is unreadable', () => { + const result = applyPatches({ + binaryPath: '/nonexistent/path/to/claude/binary', + config: buildConfig(), + overlays: null, + }); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.reason, 'io-error'); +}); diff --git a/test/core/binary-patcher/codesign.test.ts b/test/core/binary-patcher/codesign.test.ts new file mode 100644 index 0000000..24385bb --- /dev/null +++ b/test/core/binary-patcher/codesign.test.ts @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { tryAdhocSign } from '../../../src/core/binary-patcher/codesign.js'; + +test('tryAdhocSign returns no-codesign on non-darwin platforms', () => { + if (process.platform === 'darwin') { + // On darwin we can't reliably exercise the non-darwin branch; just skip. + // The orchestrator's behaviour on non-darwin is exercised by the cross-platform + // applyPatches tests (which never trigger codesign on ELF/PE). + return; + } + const result = tryAdhocSign('/some/path/that/does/not/matter'); + assert.equal(result.signed, false); + assert.equal(result.reason, 'no-codesign'); +}); + +test('tryAdhocSign returns failed when codesign rejects the input on darwin', () => { + if (process.platform !== 'darwin') return; + // /etc/hosts is not a Mach-O binary; codesign should refuse. + const result = tryAdhocSign('/etc/hosts'); + // Either failed (codesign present, refused the input) or no-codesign (CI without Xcode CLT). + assert.equal(result.signed, false); + assert.ok( + result.reason === 'failed' || result.reason === 'no-codesign', + `expected failed or no-codesign, got ${result.reason}` + ); +}); diff --git a/test/core/binary-patcher/integration.test.ts b/test/core/binary-patcher/integration.test.ts new file mode 100644 index 0000000..b3b6855 --- /dev/null +++ b/test/core/binary-patcher/integration.test.ts @@ -0,0 +1,91 @@ +/** + * End-to-end integration test for the in-repo binary patcher. + * + * Drives core.createVariantAsync against a real Claude Code binary downloaded + * from the canonical install manifest. Asserts the wrapper launches post- + * patch and that platform-specific outcomes are correct: + * - linux ELF / win32 PE: applyPatches resizes the entry JS in place; the + * wrapper runs and reports the expected version. + * - darwin Mach-O: applyPatches detects that theme + prompt patches would + * grow the entry JS, skips the patch, and surfaces a clear note. The + * pristine binary stays in place and still launches. + * + * Network-gated by CC_MIRROR_NETWORK_TESTS=1 because the test downloads + * ~200 MB of binary on first run. CI sets this env var; local runs skip + * by default. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as core from '../../../src/core/index.js'; +import { cleanup, makeTempDir } from '../../helpers/index.js'; + +const NETWORK_TESTS_ENABLED = process.env.CC_MIRROR_NETWORK_TESTS === '1'; +const isWindows = process.platform === 'win32'; +const isMac = process.platform === 'darwin'; + +test( + 'createVariantAsync drives the in-repo patcher end-to-end', + { skip: !NETWORK_TESTS_ENABLED || isWindows }, + async () => { + const rootDir = makeTempDir(); + const binDir = makeTempDir(); + + try { + const result = await core.createVariantAsync({ + name: 'patcher-it', + providerKey: 'minimax', + apiKey: '', + claudeVersion: 'stable', + rootDir, + binDir, + noTweak: false, + promptPack: true, + skillInstall: false, + tweakccStdio: 'pipe', + }); + + // Wrapper must exist and launch. + const wrapperPath = path.join(binDir, 'patcher-it'); + assert.ok(fs.existsSync(wrapperPath), `wrapper missing at ${wrapperPath}`); + + // Variant should be functional regardless of patch outcome. + assert.equal(result.meta.tweakRolledBack ?? false, false, 'patcher should not have rolled back'); + + if (isMac) { + // Mach-O: patch is skipped because theme + overlays grow the entry JS. + // Note is appended; tweakResult.status is 0 (the skip is a successful + // outcome, not a failure). + assert.ok(result.tweakResult, 'expected tweakResult to be set'); + assert.equal(result.tweakResult?.status, 0); + assert.ok( + result.notes?.some((n) => /Mach-O patch skipped/.test(n)), + `expected Mach-O skip note, got: ${JSON.stringify(result.notes)}` + ); + } else { + // Linux ELF: the patch should land. tweakResult.status === 0 with no + // skip note. + assert.equal(result.tweakResult?.status, 0); + assert.ok( + !result.notes?.some((n) => /Mach-O patch skipped/.test(n)), + 'should not see Mach-O skip note on linux' + ); + + // Spot-check that minimax brand theme bytes ended up in the patched binary. + const binaryPath = result.meta.binaryPath; + const buf = fs.readFileSync(binaryPath); + const text = buf.toString('latin1'); + assert.ok( + text.includes('"label":"MiniMax Nebula","value":"dark"') || + text.includes('"value":"dark","label":"MiniMax Nebula"'), + 'patched binary should contain the rewritten theme options array' + ); + } + } finally { + cleanup(rootDir); + cleanup(binDir); + } + } +); diff --git a/test/core/binary-patcher/js-patch.test.ts b/test/core/binary-patcher/js-patch.test.ts new file mode 100644 index 0000000..8a1265c --- /dev/null +++ b/test/core/binary-patcher/js-patch.test.ts @@ -0,0 +1,155 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + UnpackedManifestError, + patchUnpackedEntry, + resolveEntryPath, +} from '../../../src/core/binary-patcher/js-patch.js'; +import { OVERLAY_MARKERS } from '../../../src/core/binary-patcher/prompts.js'; +import { ThemeAnchorNotFound } from '../../../src/core/binary-patcher/theme.js'; +import type { TweakccConfig } from '../../../src/brands/types.js'; +import type { OverlayMap } from '../../../src/core/prompt-pack/types.js'; + +const themes: TweakccConfig['settings']['themes'] = [ + { id: 'dark', name: 'Dark mode', colors: { bashBorder: '#fff', autoAccept: '#0f0', text: '#aaa' } }, + { id: 'zai-gold', name: 'Z.ai gold', colors: { bashBorder: '#daa', autoAccept: '#fda', text: '#bbb' } }, +]; + +const config: TweakccConfig = { + ccVersion: '2.1.119', + ccInstallationPath: null, + lastModified: '2026-04-26T00:00:00Z', + changesApplied: false, + hidePiebaldAnnouncement: true, + settings: { + themes, + thinkingVerbs: { format: '...', verbs: [] }, + thinkingStyle: { reverseMirror: false, updateInterval: 100, phases: [] }, + userMessageDisplay: { + format: '> {}', + styling: [], + foregroundColor: 'default', + backgroundColor: null, + borderStyle: 'round', + borderColor: 'default', + paddingX: 1, + paddingY: 0, + fitBoxToContent: true, + }, + inputBox: { removeBorder: false }, + misc: { + showTweakccVersion: false, + showPatchesApplied: false, + expandThinkingBlocks: false, + enableConversationTitle: false, + hideStartupBanner: false, + hideCtrlGToEdit: false, + hideStartupClawd: false, + increaseFileReadLimit: false, + suppressLineNumbers: false, + suppressRateLimitOptions: false, + mcpConnectionNonBlocking: false, + mcpServerBatchSize: null, + statuslineThrottleMs: null, + statuslineUseFixedInterval: false, + tableFormat: 'default', + enableSwarmMode: false, + enableSessionMemory: false, + enableRememberSkill: false, + tokenCountRounding: null, + autoAcceptPlanMode: false, + allowBypassPermissionsInSudo: null, + suppressNativeInstallerWarning: false, + filterScrollEscapeSequences: false, + }, + claudeMdAltNames: null, + }, +}; + +const ENTRY_BODY = [ + 'function getNames(){return{"dark":"Dark mode","light":"Light mode"}}', + 'const themeOptions=[{label:"Dark mode",value:"dark"},{label:"Light mode",value:"light"}];', + 'function pickTheme(A){switch(A){case"light":return LX9;case"dark":return CX9;default:return CX9}}', + 'const explorePrompt=`...lots of text...', + "Complete the user's search request efficiently and report your findings clearly.`", +].join('\n'); + +const wrapBunCjs = (body: string): string => + `// @bun @bytecode @bun-cjs\n(function(exports, require, module, __filename, __dirname) {${body}})`; + +const setupUnpacked = (entryBody: string, opts: { wrap?: boolean } = {}): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-jspatch-')); + fs.mkdirSync(path.join(dir, 'src', 'entrypoints'), { recursive: true }); + const entryRel = 'src/entrypoints/cli.js'; + const entryAbs = path.join(dir, entryRel); + fs.writeFileSync(entryAbs, opts.wrap === false ? entryBody : wrapBunCjs(entryBody), 'latin1'); + fs.writeFileSync( + path.join(dir, 'manifest.json'), + JSON.stringify({ entryPoint: entryRel, entryPointId: 0, modules: [{ name: entryRel, isEntry: true }] }), + 'utf8' + ); + return dir; +}; + +test('patchUnpackedEntry strips wrapper, applies theme, and writes back', () => { + const dir = setupUnpacked(ENTRY_BODY); + + const result = patchUnpackedEntry({ unpackedDir: dir, config, overlays: null }); + + assert.equal(result.themeReplaced, 3); + const written = fs.readFileSync(result.entryPath, 'latin1'); + assert.ok(!written.startsWith('// @bun'), 'leading bun annotation should be gone'); + assert.ok(!written.startsWith('(function('), 'wrapper open should be stripped'); + assert.match(written, /case"zai-gold":return\{"bashBorder":"#daa"/); +}); + +test('patchUnpackedEntry applies prompt overlays via tail anchor', () => { + const dir = setupUnpacked(ENTRY_BODY); + const overlays: OverlayMap = { explore: 'Z.ai routing rule: prefer search via zai-cli.' }; + + const result = patchUnpackedEntry({ unpackedDir: dir, config, overlays }); + + assert.deepEqual(result.promptReplaced, ['explore']); + const written = fs.readFileSync(result.entryPath, 'latin1'); + assert.ok(written.includes(OVERLAY_MARKERS.start), 'overlay start marker should be present'); + assert.ok(written.includes('Z.ai routing rule'), 'overlay text should be inserted'); +}); + +test('patchUnpackedEntry replaces an existing overlay block instead of duplicating', () => { + // Pre-seed the entry with an existing overlay block right after the explore + // tail anchor. patchUnpackedEntry should replace it, not append a second one. + const tail = "Complete the user's search request efficiently and report your findings clearly."; + const seeded = ENTRY_BODY.replace(tail, `${tail}\n\n${OVERLAY_MARKERS.start}\nOverlay v1\n${OVERLAY_MARKERS.end}\n`); + const dir = setupUnpacked(seeded); + + const result = patchUnpackedEntry({ unpackedDir: dir, config, overlays: { explore: 'Overlay v2' } }); + + assert.deepEqual(result.promptReplaced, ['explore']); + const written = fs.readFileSync(result.entryPath, 'latin1'); + const startCount = written.split(OVERLAY_MARKERS.start).length - 1; + assert.equal(startCount, 1, 'should have exactly one overlay block'); + assert.ok(written.includes('Overlay v2')); + assert.ok(!written.includes('Overlay v1')); +}); + +test('patchUnpackedEntry throws ThemeAnchorNotFound on broken theme anchors', () => { + const broken = ENTRY_BODY.replace(/switch\(A\)\{[^}]*\}/, '/* removed */'); + const dir = setupUnpacked(broken); + + assert.throws( + () => patchUnpackedEntry({ unpackedDir: dir, config, overlays: null }), + (err: unknown) => err instanceof ThemeAnchorNotFound + ); +}); + +test('resolveEntryPath throws when manifest is missing', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-jspatch-no-mf-')); + assert.throws( + () => resolveEntryPath(dir), + (err: unknown) => err instanceof UnpackedManifestError + ); +}); diff --git a/test/core/binary-patcher/prompts.test.ts b/test/core/binary-patcher/prompts.test.ts new file mode 100644 index 0000000..b5b472c --- /dev/null +++ b/test/core/binary-patcher/prompts.test.ts @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { OVERLAY_MARKERS, applyPrompts } from '../../../src/core/binary-patcher/prompts.js'; + +const WEBFETCH_TAIL = + '- For GitHub URLs, prefer using the gh CLI via Bash instead (e.g., gh pr view, gh issue view, gh api).'; + +// Synthesise a minimal cli.js where each prompt sits in a template literal, +// matching how Bun-compiled Claude Code wraps interpolated prompts. +const buildFixture = (): string => { + const webfetchPrompt = `let WEBFETCH=\`Fetches and processes URLs. + + - HTTP URLs are upgraded to HTTPS + - When the prompt mentions a host name, prefer using the canonical name + - Returns may be summarized for very large content + - When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content. + - For GitHub URLs, prefer using the gh CLI via Bash instead (e.g., gh pr view, gh issue view, gh api).\`;`; + + const skillPrompt = `function getSkill(){return \`Execute a skill. + +If you see a <\${TAG}> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling this tool again\`}`; + + return [webfetchPrompt, skillPrompt].join('\n\n'); +}; + +test('applyPrompts splices an overlay after the webfetch tail anchor', () => { + const fixture = buildFixture(); + const result = applyPrompts(fixture, { webfetch: 'Use zai-cli read instead.' }); + + assert.deepEqual(result.replacedTargets, ['webfetch']); + assert.deepEqual(result.missing, []); + assert.ok(result.js.includes(WEBFETCH_TAIL)); + assert.ok(result.js.includes(OVERLAY_MARKERS.start)); + assert.ok(result.js.includes('Use zai-cli read instead.')); + assert.ok(result.js.includes(OVERLAY_MARKERS.end)); + + const startIdx = result.js.indexOf(OVERLAY_MARKERS.start); + const tailIdx = result.js.indexOf(WEBFETCH_TAIL); + assert.ok(startIdx > tailIdx, 'overlay block should sit after the tail anchor'); +}); + +test('applyPrompts is idempotent: re-applying replaces the existing block instead of duplicating', () => { + const fixture = buildFixture(); + const first = applyPrompts(fixture, { webfetch: 'first overlay text' }); + const second = applyPrompts(first.js, { webfetch: 'second overlay text' }); + + const startCount = (second.js.match(/cc-mirror:provider-overlay start/g) ?? []).length; + const endCount = (second.js.match(/cc-mirror:provider-overlay end/g) ?? []).length; + assert.equal(startCount, 1, 'should not duplicate start markers'); + assert.equal(endCount, 1, 'should not duplicate end markers'); + assert.ok(second.js.includes('second overlay text')); + assert.ok(!second.js.includes('first overlay text'), 'old overlay text should be replaced'); +}); + +test('applyPrompts handles multiple overlay keys in one call', () => { + const fixture = buildFixture(); + const result = applyPrompts(fixture, { + webfetch: 'web overlay', + skill: 'skill overlay', + }); + assert.deepEqual(result.replacedTargets.sort(), ['skill', 'webfetch']); + assert.ok(result.js.includes('web overlay')); + assert.ok(result.js.includes('skill overlay')); +}); + +test('applyPrompts records keys whose anchor is missing without throwing', () => { + const trimmed = buildFixture().replace(WEBFETCH_TAIL, ''); + const result = applyPrompts(trimmed, { webfetch: 'will not splice' }); + assert.deepEqual(result.replacedTargets, []); + assert.deepEqual(result.missing, ['webfetch']); + assert.ok(!result.js.includes('will not splice')); +}); + +test('applyPrompts records unknown OverlayKeys as missing', () => { + const fixture = buildFixture(); + // 'main' is a known OverlayKey but currently has no anchor in cc-mirror. + const result = applyPrompts(fixture, { main: 'overlay text' }); + assert.deepEqual(result.replacedTargets, []); + assert.deepEqual(result.missing, ['main']); +}); + +test('applyPrompts skips empty or whitespace-only overlay text', () => { + const fixture = buildFixture(); + const result = applyPrompts(fixture, { webfetch: ' \n \t\n' }); + assert.deepEqual(result.replacedTargets, []); + assert.deepEqual(result.missing, []); + assert.equal(result.js, fixture); +}); + +test('applyPrompts escapes backticks and ${ in overlay text for template-literal prompts', () => { + const fixture = buildFixture(); + const dangerous = 'Use `npx zai-cli` to ${run} commands'; + const result = applyPrompts(fixture, { webfetch: dangerous }); + assert.deepEqual(result.replacedTargets, ['webfetch']); + assert.ok(result.js.includes('Use \\`npx zai-cli\\` to \\${run} commands')); + assert.ok(!result.js.includes('Use `npx zai-cli`'), 'raw backticks must not remain inside the template literal'); +}); + +test('applyPrompts leaves the JS untouched when overlays object is empty', () => { + const fixture = buildFixture(); + const result = applyPrompts(fixture, {}); + assert.deepEqual(result.replacedTargets, []); + assert.deepEqual(result.missing, []); + assert.equal(result.js, fixture); +}); diff --git a/test/core/binary-patcher/repack.test.ts b/test/core/binary-patcher/repack.test.ts new file mode 100644 index 0000000..7d5aeb3 --- /dev/null +++ b/test/core/binary-patcher/repack.test.ts @@ -0,0 +1,216 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseBunBinary } from '../../../src/core/bun-extract.js'; +import { replaceEntryJs } from '../../../src/core/binary-patcher/replace-entry.js'; +import { PeNotLastSectionError } from '../../../src/core/binary-patcher/pe-resize.js'; +import { OFFSETS_SIZE, PE_DOS_MAGIC, PE_NT_SIGNATURE, TRAILER } from '../../../src/core/bun-extract/constants.js'; +import { buildBunFixture, type FixtureModule } from '../../helpers/bun-fixture.js'; + +const threeModules: FixtureModule[] = [ + { name: 'src/header.js', content: 'HHHHHHHHHH' }, + { name: 'src/cli.js', content: 'CCCCCCCCCCCCCCCC' }, // entry, 16 bytes + { name: 'src/footer.js', content: 'FFFFFFFFFFFFFFFFFFFF' }, +]; + +const platforms: Array<'elf' | 'macho' | 'pe'> = ['elf', 'macho', 'pe']; + +/** Reads back the raw bytes for a module by index and returns them as utf8. */ +const readModuleContent = (buf: Buffer, info: ReturnType, index: number): string => { + const mod = info.modules[index]; + return buf.subarray(info.dataStart + mod.contOff, info.dataStart + mod.contOff + mod.contLen).toString('utf8'); +}; + +for (const platform of platforms) { + test(`replaceEntryJs grows the entry on ${platform}`, () => { + const fix = buildBunFixture({ platform, moduleStructSize: 52, modules: threeModules, entryPointId: 1 }); + const info = parseBunBinary(fix.buf); + const newContent = Buffer.from('X'.repeat(64)); + const result = replaceEntryJs(fix.buf, info, newContent); + + assert.equal(result.delta, newContent.length - 16); + + const reparsed = parseBunBinary(result.buf); + assert.equal(reparsed.modules.length, 3); + assert.equal(reparsed.entryPointId, 1); + assert.equal(readModuleContent(result.buf, reparsed, 0), 'HHHHHHHHHH'); + assert.equal(readModuleContent(result.buf, reparsed, 1), 'X'.repeat(64)); + assert.equal(readModuleContent(result.buf, reparsed, 2), 'FFFFFFFFFFFFFFFFFFFF'); + assert.equal(reparsed.modules[0].name, 'src/header.js'); + assert.equal(reparsed.modules[1].name, 'src/cli.js'); + assert.equal(reparsed.modules[2].name, 'src/footer.js'); + }); + + test(`replaceEntryJs shrinks the entry on ${platform}`, () => { + const fix = buildBunFixture({ platform, moduleStructSize: 52, modules: threeModules, entryPointId: 1 }); + const info = parseBunBinary(fix.buf); + const newContent = Buffer.from('xy'); + const result = replaceEntryJs(fix.buf, info, newContent); + + assert.equal(result.delta, 2 - 16); + + const reparsed = parseBunBinary(result.buf); + assert.equal(readModuleContent(result.buf, reparsed, 1), 'xy'); + assert.equal(readModuleContent(result.buf, reparsed, 0), 'HHHHHHHHHH'); + assert.equal(readModuleContent(result.buf, reparsed, 2), 'FFFFFFFFFFFFFFFFFFFF'); + }); + + test(`replaceEntryJs handles same-size on ${platform}`, () => { + const fix = buildBunFixture({ platform, moduleStructSize: 52, modules: threeModules, entryPointId: 1 }); + const info = parseBunBinary(fix.buf); + const newContent = Buffer.from('1234567890ABCDEF'); // 16 bytes, same as entry + const result = replaceEntryJs(fix.buf, info, newContent); + + assert.equal(result.delta, 0); + + const reparsed = parseBunBinary(result.buf); + assert.equal(readModuleContent(result.buf, reparsed, 1), '1234567890ABCDEF'); + assert.equal(readModuleContent(result.buf, reparsed, 0), 'HHHHHHHHHH'); + assert.equal(readModuleContent(result.buf, reparsed, 2), 'FFFFFFFFFFFFFFFFFFFF'); + }); + + test(`replaceEntryJs works with v36 module struct on ${platform}`, () => { + const fix = buildBunFixture({ platform, moduleStructSize: 36, modules: threeModules, entryPointId: 1 }); + const info = parseBunBinary(fix.buf); + assert.equal(info.moduleSize, 36); + const newContent = Buffer.from('Z'.repeat(40)); + const result = replaceEntryJs(fix.buf, info, newContent); + + const reparsed = parseBunBinary(result.buf); + assert.equal(reparsed.moduleSize, 36); + assert.equal(readModuleContent(result.buf, reparsed, 1), 'Z'.repeat(40)); + assert.equal(readModuleContent(result.buf, reparsed, 0), 'HHHHHHHHHH'); + assert.equal(readModuleContent(result.buf, reparsed, 2), 'FFFFFFFFFFFFFFFFFFFF'); + }); + + test(`replaceEntryJs handles entryPointId=0 (entry first) on ${platform}`, () => { + const fix = buildBunFixture({ platform, moduleStructSize: 52, modules: threeModules, entryPointId: 0 }); + const info = parseBunBinary(fix.buf); + const newContent = Buffer.from('NEWHEADER123456789012'); + const result = replaceEntryJs(fix.buf, info, newContent); + + const reparsed = parseBunBinary(result.buf); + assert.equal(readModuleContent(result.buf, reparsed, 0), 'NEWHEADER123456789012'); + assert.equal(readModuleContent(result.buf, reparsed, 1), 'CCCCCCCCCCCCCCCC'); + assert.equal(readModuleContent(result.buf, reparsed, 2), 'FFFFFFFFFFFFFFFFFFFF'); + }); + + test(`replaceEntryJs handles entryPointId=last on ${platform}`, () => { + const fix = buildBunFixture({ platform, moduleStructSize: 52, modules: threeModules, entryPointId: 2 }); + const info = parseBunBinary(fix.buf); + const newContent = Buffer.from('NEWFOOTER'); + const result = replaceEntryJs(fix.buf, info, newContent); + + const reparsed = parseBunBinary(result.buf); + assert.equal(readModuleContent(result.buf, reparsed, 0), 'HHHHHHHHHH'); + assert.equal(readModuleContent(result.buf, reparsed, 1), 'CCCCCCCCCCCCCCCC'); + assert.equal(readModuleContent(result.buf, reparsed, 2), 'NEWFOOTER'); + }); +} + +test('replaceEntryJs strips LC_CODE_SIGNATURE on Mach-O when present', () => { + const fix = buildBunFixture({ + platform: 'macho', + moduleStructSize: 52, + modules: threeModules, + entryPointId: 1, + withCodeSignature: true, + trailingPadding: 256, + }); + const info = parseBunBinary(fix.buf); + assert.equal(info.hasCodeSignature, true); + + const result = replaceEntryJs(fix.buf, info, Buffer.from('Y'.repeat(32))); + assert.equal(result.signatureInvalidated, true); + assert.equal(result.signatureStripped, true); + + const reparsed = parseBunBinary(result.buf); + assert.equal(reparsed.hasCodeSignature, false, 'LC_CODE_SIGNATURE should be gone after repack'); + assert.equal(readModuleContent(result.buf, reparsed, 1), 'Y'.repeat(32)); +}); + +test('replaceEntryJs leaves Mach-O without code signature unchanged in flags', () => { + const fix = buildBunFixture({ + platform: 'macho', + moduleStructSize: 52, + modules: threeModules, + entryPointId: 1, + withCodeSignature: false, + }); + const info = parseBunBinary(fix.buf); + assert.equal(info.hasCodeSignature, false); + + const result = replaceEntryJs(fix.buf, info, Buffer.from('YYYY')); + assert.equal(result.signatureStripped, false); + assert.equal(result.signatureInvalidated, false); +}); + +test('PE last-section guard rejects a binary where .bun is not last', () => { + // Hand-build a PE with two sections: .bun then .extra (both at non-overlapping offsets). + const dos = Buffer.alloc(0x80); + dos.writeUInt16LE(PE_DOS_MAGIC, 0); + dos.writeUInt32LE(0x80, 0x3c); + + const peStart = 0x80; + // PE\0\0 + COFF FileHeader (20) = 24 bytes; we set NumberOfSections=2, SizeOfOptional=0. + const coff = Buffer.alloc(24); + coff.writeUInt32LE(PE_NT_SIGNATURE, 0); + coff.writeUInt16LE(2, 6); + coff.writeUInt16LE(0, 20); + + // Two section headers (40 bytes each). + const section1 = Buffer.alloc(40); + Buffer.from('.bun\0').copy(section1, 0); + const section2 = Buffer.alloc(40); + Buffer.from('.extra\0').copy(section2, 0); + + // Headers blob: dos (0x80) + coff (24) + section1 (40) + section2 (40) = 264 bytes. + const headersLen = peStart + coff.length + section1.length + section2.length; + // Place .bun at offset 264, then .extra after .bun's payload. + const bunPayload = Buffer.concat([Buffer.from('hello'), Buffer.alloc(OFFSETS_SIZE), TRAILER]); + const bunPtr = headersLen; + const bunSize = bunPayload.length; + const extraPtr = bunPtr + bunSize; + const extraSize = 16; + + section1.writeUInt32LE(bunSize, 16); + section1.writeUInt32LE(bunPtr, 20); + section2.writeUInt32LE(extraSize, 16); + section2.writeUInt32LE(extraPtr, 20); + + const fakeBuf = Buffer.concat([dos, coff, section1, section2, bunPayload, Buffer.alloc(extraSize)]); + + // Force-construct a BunBinaryInfo-shaped object the resize path will accept. + // We don't go through parseBunBinary because the synthetic .bun payload here + // is intentionally minimal (no real module table). The PE guard should fail + // before the rewrite touches anything. + const fakeInfo = { + platform: 'pe' as const, + dataStart: bunPtr, + trailerOffset: bunPtr + 5 + OFFSETS_SIZE, + byteCount: 5, + moduleSize: 52 as const, + modules: [], + entryPointId: 0, + flags: 0, + sectionOffset: bunPtr, + sectionSize: bunSize, + hasCodeSignature: false, + bunVersionHint: '>=1.3.13' as const, + }; + + // We can't actually call replaceEntryJs with empty modules; instead exercise + // the repack guard directly by importing repackPe. + return import('../../../src/core/binary-patcher/pe-resize.js').then(({ repackPe }) => { + assert.throws( + () => + repackPe({ + buf: fakeBuf, + info: fakeInfo, + newRawBytes: Buffer.from('hi'), + newOffsetsStruct: Buffer.alloc(OFFSETS_SIZE), + }), + (err: unknown) => err instanceof PeNotLastSectionError + ); + }); +}); diff --git a/test/core/binary-patcher/strip-bun-wrapper.test.ts b/test/core/binary-patcher/strip-bun-wrapper.test.ts new file mode 100644 index 0000000..5096e71 --- /dev/null +++ b/test/core/binary-patcher/strip-bun-wrapper.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { BunWrapperNotFound, stripBunWrapper } from '../../../src/core/binary-patcher/strip-bun-wrapper.js'; + +const wrap = (body: string): string => + `// @bun @bytecode @bun-cjs\n(function(exports, require, module, __filename, __dirname) {${body}})`; + +test('stripBunWrapper removes the Bun CJS wrapper', () => { + const body = 'console.log("hi");'; + assert.equal(stripBunWrapper(wrap(body)), body); +}); + +test('stripBunWrapper handles trailing whitespace + semicolon', () => { + const body = 'console.log("hi");'; + assert.equal(stripBunWrapper(`${wrap(body)};\n `), body); +}); + +test('stripBunWrapper preserves nested braces in the body', () => { + const body = 'function f(){return{a:1,b:{c:2}}}f();'; + assert.equal(stripBunWrapper(wrap(body)), body); +}); + +test('stripBunWrapper is a no-op on a file without the wrapper', () => { + const plain = 'module.exports = { ok: true };'; + assert.equal(stripBunWrapper(plain), plain); +}); + +test('stripBunWrapper throws when the close anchor is missing', () => { + const broken = '// @bun foo\n(function(a, b) {let x = 1;\n'; + assert.throws( + () => stripBunWrapper(broken), + (err: unknown) => err instanceof BunWrapperNotFound && err.anchor === 'close' + ); +}); + +test('stripBunWrapper throws when @bun marker exists but signature is malformed', () => { + const broken = '// @bun foo\nnotAFunctionExpression()'; + assert.throws( + () => stripBunWrapper(broken), + (err: unknown) => err instanceof BunWrapperNotFound && err.anchor === 'open' + ); +}); diff --git a/test/core/binary-patcher/theme.test.ts b/test/core/binary-patcher/theme.test.ts new file mode 100644 index 0000000..39cc544 --- /dev/null +++ b/test/core/binary-patcher/theme.test.ts @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { ThemeAnchorNotFound, applyTheme } from '../../../src/core/binary-patcher/theme.js'; +import type { Theme } from '../../../src/brands/types.js'; + +const themes: Theme[] = [ + { + id: 'dark', + name: 'Dark mode', + colors: { bashBorder: '#fff', autoAccept: '#0f0', text: '#aaa' }, + }, + { + id: 'zai-gold', + name: 'Z.ai gold', + colors: { bashBorder: '#daa', autoAccept: '#fda', text: '#bbb' }, + }, +]; + +// Modeled after CC >=2.1.83 minified output. Order in the file must match the +// patcher's empirical assumption: obj < objArr < switch. +const NEW_FORMAT_FIXTURE = [ + 'function getNames(){return{"dark":"Dark mode","light":"Light mode","zaiGold":"Auto Z.ai gold"}}', + 'const themeOptions=[{label:"Dark mode",value:"dark"},{label:"Light mode",value:"light"}];', + 'function pickTheme(A){switch(A){case"light":return LX9;case"dark":return CX9;default:return CX9}}', +].join('\n'); + +const OLD_FORMAT_FIXTURE = [ + 'function getNames(){return{"dark":"Dark mode","light":"Light mode"}}', + 'const themeOptions=[{label:"Dark mode",value:"dark"},{label:"Light mode",value:"light"}];', + 'function pickTheme(A){switch(A){case"dark":return{"autoAccept":"#0f0","bashBorder":"#fff","text":"#aaa"};default:return{"autoAccept":"#0f0","bashBorder":"#fff","text":"#aaa"}}}', +].join('\n'); + +test('applyTheme rewrites obj, objArr, and switch on new-format CC bundle', () => { + const result = applyTheme(NEW_FORMAT_FIXTURE, themes); + assert.equal(result.replaced, 3); + assert.match(result.js, /case"dark":return\{"bashBorder":"#fff"/); + assert.match(result.js, /case"zai-gold":return\{"bashBorder":"#daa"/); + assert.match(result.js, /default:return\{"bashBorder":"#fff"/); + assert.match(result.js, /\[\{"label":"Dark mode","value":"dark"\},\{"label":"Z\.ai gold","value":"zai-gold"\}\]/); + assert.match(result.js, /return\{"dark":"Dark mode","zai-gold":"Z\.ai gold"\}/); +}); + +test('applyTheme rewrites old-format CC bundle (inline objects)', () => { + const result = applyTheme(OLD_FORMAT_FIXTURE, themes); + assert.equal(result.replaced, 3); + assert.match(result.js, /case"zai-gold":return\{"bashBorder":"#daa"/); +}); + +test('applyTheme is a no-op when themes list is empty', () => { + const result = applyTheme(NEW_FORMAT_FIXTURE, []); + assert.equal(result.replaced, 0); + assert.equal(result.js, NEW_FORMAT_FIXTURE); +}); + +test('applyTheme throws ThemeAnchorNotFound when switch anchor is missing', () => { + const broken = NEW_FORMAT_FIXTURE.replace(/switch\(A\)\{[^}]*\}\}/, '/* removed */'); + assert.throws( + () => applyTheme(broken, themes), + (err: unknown) => err instanceof ThemeAnchorNotFound && err.anchor === 'switch' + ); +}); + +test('applyTheme throws ThemeAnchorNotFound when objArr anchor is missing', () => { + const broken = NEW_FORMAT_FIXTURE.replace(/const themeOptions=\[[^;]+\];/, '/* removed */'); + assert.throws( + () => applyTheme(broken, themes), + (err: unknown) => err instanceof ThemeAnchorNotFound && err.anchor === 'objArr' + ); +}); + +test('applyTheme throws ThemeAnchorNotFound when obj anchor is missing', () => { + const broken = NEW_FORMAT_FIXTURE.replace(/function getNames[^}]+\}\}/, '/* removed */'); + assert.throws( + () => applyTheme(broken, themes), + (err: unknown) => err instanceof ThemeAnchorNotFound && err.anchor === 'obj' + ); +}); diff --git a/test/core/bun-extract-replace.test.ts b/test/core/bun-extract-replace.test.ts new file mode 100644 index 0000000..a75eccf --- /dev/null +++ b/test/core/bun-extract-replace.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { ModuleNotFound, SizeMismatch, parseBunBinary, replaceModule } from '../../src/core/bun-extract.js'; +import { buildBunFixture } from '../helpers/bun-fixture.js'; + +const modules = [ + { name: 'src/cli.js', content: 'AAAAAAAAAA' }, + { name: 'src/lib.js', content: 'BBBBBBBBBB' }, +]; + +test('replaceModule round-trips a same-size content swap', () => { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 52, modules }); + const info = parseBunBinary(fix.buf); + const replacement = Buffer.from('CCCCCCCCCC'); + const result = replaceModule(fix.buf, info, 'src/cli.js', replacement); + assert.equal(result.signatureInvalidated, false); + + const reparsed = parseBunBinary(result.buf); + const cli = reparsed.modules.find((m) => m.name === 'src/cli.js'); + assert.ok(cli, 'cli module should be present after replace'); + const slice = result.buf.subarray(reparsed.dataStart + cli.contOff, reparsed.dataStart + cli.contOff + cli.contLen); + assert.equal(slice.toString('utf8'), 'CCCCCCCCCC'); +}); + +test('replaceModule throws SizeMismatch when sizes differ', () => { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 52, modules }); + const info = parseBunBinary(fix.buf); + assert.throws(() => replaceModule(fix.buf, info, 'src/cli.js', Buffer.from('shorter')), SizeMismatch); +}); + +test('replaceModule throws ModuleNotFound for unknown modules', () => { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 52, modules }); + const info = parseBunBinary(fix.buf); + assert.throws(() => replaceModule(fix.buf, info, 'nope.js', Buffer.alloc(0)), ModuleNotFound); +}); + +test('replaceModule flags Mach-O code signature as invalidated', () => { + const fix = buildBunFixture({ + platform: 'macho', + moduleStructSize: 52, + modules, + withCodeSignature: true, + trailingPadding: 512, + }); + const info = parseBunBinary(fix.buf); + const result = replaceModule(fix.buf, info, 'src/cli.js', Buffer.from('CCCCCCCCCC')); + assert.equal(result.signatureInvalidated, true); +}); + +test('replaceModule on Mach-O without signature reports signatureInvalidated=false', () => { + const fix = buildBunFixture({ + platform: 'macho', + moduleStructSize: 52, + modules, + withCodeSignature: false, + }); + const info = parseBunBinary(fix.buf); + const result = replaceModule(fix.buf, info, 'src/cli.js', Buffer.from('CCCCCCCCCC')); + assert.equal(result.signatureInvalidated, false); +}); diff --git a/test/core/bun-extract.test.ts b/test/core/bun-extract.test.ts new file mode 100644 index 0000000..3d23b75 --- /dev/null +++ b/test/core/bun-extract.test.ts @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { BunFormatError, extractAll, parseBunBinary } from '../../src/core/bun-extract.js'; +import { OFFSETS_SIZE } from '../../src/core/bun-extract/constants.js'; +import { buildBunFixture } from '../helpers/bun-fixture.js'; +import { cleanup, makeTempDir } from '../helpers/fs-helpers.js'; + +const sampleModules = [ + { name: 'src/entrypoints/cli.js', content: '/* @bun */ console.log("hello")' }, + { name: 'src/lib/util.js', content: 'export const ok = true' }, + { name: 'node_modules/foo/index.js', content: 'module.exports = 42' }, +]; + +test('parseBunBinary handles ELF with v52 module struct', () => { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 52, modules: sampleModules, entryPointId: 0 }); + const info = parseBunBinary(fix.buf); + assert.equal(info.platform, 'elf'); + assert.equal(info.moduleSize, 52); + assert.equal(info.bunVersionHint, '>=1.3.13'); + assert.equal(info.modules.length, 3); + assert.equal(info.modules[0].name, 'src/entrypoints/cli.js'); + assert.equal(info.modules[0].isEntry, true); + assert.equal(info.dataStart, fix.expected.dataStart); +}); + +test('parseBunBinary handles ELF with v36 module struct', () => { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 36, modules: sampleModules }); + const info = parseBunBinary(fix.buf); + assert.equal(info.moduleSize, 36); + assert.equal(info.bunVersionHint, 'pre-1.3.13'); + assert.equal(info.modules.length, 3); + assert.equal(info.modules[1].name, 'src/lib/util.js'); +}); + +test('parseBunBinary regression: ELF dataStart must be trailerOffset - byteCount - OFFSETS_SIZE', () => { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 52, modules: sampleModules }); + const info = parseBunBinary(fix.buf); + // The legacy formula `trailerOffset + trailerLen - byteCount` would land 48 bytes past the real start. + const legacyDataStart = info.trailerOffset + 16 /* trailer length */ - info.byteCount; + assert.notEqual(info.dataStart, legacyDataStart); + assert.equal(info.dataStart, info.trailerOffset - info.byteCount - OFFSETS_SIZE); +}); + +test('parseBunBinary handles Mach-O __BUN section with size header', () => { + const fix = buildBunFixture({ + platform: 'macho', + moduleStructSize: 52, + modules: sampleModules, + withCodeSignature: true, + trailingPadding: 1024, + }); + if (fix.platform !== 'macho') throw new Error('expected macho fixture'); + const info = parseBunBinary(fix.buf); + assert.equal(info.platform, 'macho'); + assert.equal(info.dataStart, fix.expected.dataStart); + assert.equal(info.sectionOffset, fix.expected.sectionOffset); + assert.equal(info.hasCodeSignature, true); + assert.equal(info.modules[0].name, 'src/entrypoints/cli.js'); +}); + +test('parseBunBinary handles PE .bun section without size header', () => { + const fix = buildBunFixture({ platform: 'pe', moduleStructSize: 52, modules: sampleModules }); + const info = parseBunBinary(fix.buf); + assert.equal(info.platform, 'pe'); + assert.equal(info.dataStart, fix.expected.dataStart); + assert.equal(info.modules.length, 3); +}); + +test('parseBunBinary throws BunFormatError when neither MODULE_SIZE validates', () => { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 52, modules: sampleModules }); + // Corrupt the trailer so the search fails. + fix.buf.write('GARBAGE GARBAGE!', fix.buf.length - 16); + assert.throws( + () => parseBunBinary(fix.buf), + (err: unknown) => err instanceof BunFormatError + ); +}); + +test('parseBunBinary throws on a totally non-Bun buffer', () => { + const buf = Buffer.alloc(1024, 0xab); + assert.throws(() => parseBunBinary(buf), BunFormatError); +}); + +test('extractAll writes module files and manifest', () => { + const dir = makeTempDir('bun-extract-'); + try { + const fix = buildBunFixture({ platform: 'elf', moduleStructSize: 52, modules: sampleModules }); + const info = parseBunBinary(fix.buf); + const result = extractAll(fix.buf, info, dir); + assert.ok(result.manifestPath); + const cli = path.join(dir, 'src/entrypoints/cli.js'); + assert.ok(fs.existsSync(cli)); + assert.equal(fs.readFileSync(cli, 'utf8'), '/* @bun */ console.log("hello")'); + const manifest = JSON.parse(fs.readFileSync(result.manifestPath, 'utf8')); + assert.equal(manifest.entryPoint, 'src/entrypoints/cli.js'); + assert.equal(manifest.platform, 'elf'); + assert.equal(manifest.modules.length, 3); + } finally { + cleanup(dir); + } +}); + +test('extractAll refuses path traversal', () => { + const dir = makeTempDir('bun-extract-'); + try { + const fix = buildBunFixture({ + platform: 'elf', + moduleStructSize: 52, + modules: [{ name: '../../../etc/evil', content: 'pwned' }], + }); + const info = parseBunBinary(fix.buf); + assert.throws(() => extractAll(fix.buf, info, dir), BunFormatError); + } finally { + cleanup(dir); + } +}); + +test('parseBunBinary strips $bunfs path prefixes', () => { + const fix = buildBunFixture({ + platform: 'elf', + moduleStructSize: 52, + modules: [ + { name: '/$bunfs/root/src/main.js', content: '1' }, + { name: '$bunfs/root/lib/x.js', content: '2' }, + ], + }); + const info = parseBunBinary(fix.buf); + assert.equal(info.modules[0].name, 'src/main.js'); + assert.equal(info.modules[1].name, 'lib/x.js'); +}); diff --git a/test/core/create-with-tweakcc-stub.test.ts b/test/core/create-with-tweakcc-stub.test.ts deleted file mode 100644 index cc9aedc..0000000 --- a/test/core/create-with-tweakcc-stub.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import * as core from '../../src/core/index.js'; -import { cleanup, makeTempDir } from '../helpers/index.js'; - -test('createVariantAsync runs tweakcc when enabled (via stubbed npx)', async () => { - const rootDir = makeTempDir(); - const binDir = makeTempDir(); - const stubBin = makeTempDir(); - const prevPath = process.env.PATH; - - try { - if (process.platform === 'win32') { - // GitHub Actions Windows runners expect .cmd shims for npm/npx. - const stubNpx = path.join(stubBin, 'npx.cmd'); - fs.writeFileSync(stubNpx, '@echo off\r\necho STUB_NPX_CREATE\r\nexit /b 0\r\n', { encoding: 'utf8' }); - } else { - const stubNpx = path.join(stubBin, 'npx'); - fs.writeFileSync(stubNpx, '#!/usr/bin/env bash\necho STUB_NPX_CREATE\nexit 0\n', { - encoding: 'utf8', - mode: 0o755, - }); - } - - process.env.PATH = `${stubBin}${path.delimiter}${prevPath || ''}`; - - const result = await core.createVariantAsync({ - name: 'tweakcc-stub', - providerKey: 'minimax', - apiKey: '', - claudeVersion: 'stable', - rootDir, - binDir, - // Ensure tweakcc actually runs; keep prompt pack off so we only invoke tweakcc once. - noTweak: false, - promptPack: false, - skillInstall: false, - tweakccStdio: 'pipe', - }); - - assert.ok(result.tweakResult, 'Expected tweakResult to be populated when noTweak=false'); - assert.equal(result.tweakResult?.status, 0); - - const combined = `${result.tweakResult?.stdout ?? ''}${result.tweakResult?.stderr ?? ''}`; - assert.match(combined, /STUB_NPX_CREATE/i); - } finally { - process.env.PATH = prevPath; - cleanup(rootDir); - cleanup(binDir); - cleanup(stubBin); - } -}); diff --git a/test/core/tweakcc-command.test.ts b/test/core/tweakcc-command.test.ts deleted file mode 100644 index e80b716..0000000 --- a/test/core/tweakcc-command.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { getNpxCommand } from '../../src/core/tweakcc.js'; - -test('getNpxCommand resolves platform-appropriate executable', () => { - const expected = process.platform === 'win32' ? 'npx.cmd' : 'npx'; - assert.equal(getNpxCommand(), expected); -}); diff --git a/test/core/tweakcc-rollback.test.ts b/test/core/tweakcc-rollback.test.ts new file mode 100644 index 0000000..834bbdf --- /dev/null +++ b/test/core/tweakcc-rollback.test.ts @@ -0,0 +1,200 @@ +/** + * Tests for the safety net helpers shared by Phase 1 (TweakccStep) and Phase 2 + * (BinaryPatcherStep): smoke test, pristine restore, rollback note formatter. + * + * The full BinaryPatcherStep integration is exercised in + * test/core/binary-patcher/integration.test.ts (real binary download); we + * deliberately avoid network here. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { restorePristineBinary } from '../../src/core/install.js'; +import { formatRollbackNote, smokeTestBinary, type SmokeTestResult } from '../../src/core/tweakcc.js'; +import { cleanup, makeTempDir, writeExecutable } from '../helpers/index.js'; + +const isWindows = process.platform === 'win32'; + +const writeShellStub = (filePath: string, body: string) => { + if (isWindows) { + writeExecutable(filePath + '.cmd', body); + return filePath + '.cmd'; + } + writeExecutable(filePath, `#!/usr/bin/env bash\n${body}\n`); + return filePath; +}; + +test('smokeTestBinary returns ok=true when binary exits 0', { skip: isWindows }, () => { + const dir = makeTempDir(); + try { + const stubPath = writeShellStub(path.join(dir, 'fake-claude'), 'echo "1.2.3 (Claude Code)"\nexit 0'); + const result = smokeTestBinary(stubPath, 3000); + assert.equal(result.ok, true); + assert.equal(result.status, 0); + assert.equal(result.timedOut, false); + assert.equal(result.signal, null); + assert.match(result.stdout, /1\.2\.3/); + } finally { + cleanup(dir); + } +}); + +test('smokeTestBinary returns ok=false on non-zero exit', { skip: isWindows }, () => { + const dir = makeTempDir(); + try { + const stubPath = writeShellStub( + path.join(dir, 'fake-claude'), + 'echo "Expected CommonJS module to have a function wrapper" >&2\nexit 1' + ); + const result = smokeTestBinary(stubPath, 3000); + assert.equal(result.ok, false); + assert.equal(result.status, 1); + assert.match(result.stderr, /CommonJS module/); + } finally { + cleanup(dir); + } +}); + +test('smokeTestBinary returns ok=false on timeout', { skip: isWindows }, () => { + const dir = makeTempDir(); + try { + const stubPath = writeShellStub(path.join(dir, 'fake-claude'), 'sleep 5\nexit 0'); + const result = smokeTestBinary(stubPath, 200); + assert.equal(result.ok, false); + // Node sets either signal=SIGTERM or error.code=ETIMEDOUT depending on version; + // both must be treated as failure. + assert.ok(result.timedOut || result.signal !== null); + } finally { + cleanup(dir); + } +}); + +test('smokeTestBinary returns ok=false when binary is missing', () => { + const dir = makeTempDir(); + try { + const result = smokeTestBinary(path.join(dir, 'does-not-exist'), 1000); + assert.equal(result.ok, false); + } finally { + cleanup(dir); + } +}); + +test('restorePristineBinary copies cache to target and chmod 0755', { skip: isWindows }, () => { + const cacheDir = makeTempDir(); + const variantDir = makeTempDir(); + try { + const version = '2.1.119'; + const platform = 'darwin-arm64'; + const cachePath = path.join(cacheDir, version, platform, 'claude'); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, 'PRISTINE_BINARY_BYTES', { mode: 0o644 }); + + const binaryPath = path.join(variantDir, 'claude'); + // Pre-existing corrupt binary that should be replaced. + fs.writeFileSync(binaryPath, 'CORRUPT_BYTES', { mode: 0o755 }); + + const result = restorePristineBinary({ + binaryPath, + cacheDir, + resolvedVersion: version, + platform, + }); + + assert.equal(result.restored, true); + assert.equal(fs.readFileSync(binaryPath, 'utf8'), 'PRISTINE_BINARY_BYTES'); + const mode = fs.statSync(binaryPath).mode & 0o777; + assert.equal(mode, 0o755); + } finally { + cleanup(cacheDir); + cleanup(variantDir); + } +}); + +test('restorePristineBinary returns cache-missing when cache file absent', () => { + const cacheDir = makeTempDir(); + const variantDir = makeTempDir(); + try { + const result = restorePristineBinary({ + binaryPath: path.join(variantDir, 'claude'), + cacheDir, + resolvedVersion: '9.9.9', + platform: 'darwin-arm64', + }); + assert.equal(result.restored, false); + assert.equal(result.reason, 'cache-missing'); + assert.ok(result.cachePath?.includes('9.9.9')); + } finally { + cleanup(cacheDir); + cleanup(variantDir); + } +}); + +test('restorePristineBinary returns cache-missing on empty inputs', () => { + const result = restorePristineBinary({ + binaryPath: '/tmp/x', + cacheDir: '', + resolvedVersion: '', + platform: '', + }); + assert.equal(result.restored, false); + assert.equal(result.reason, 'cache-missing'); +}); + +test('formatRollbackNote: smoke-failed with exit code', () => { + const smoke: SmokeTestResult = { + ok: false, + status: 1, + signal: null, + stderr: 'Expected CommonJS module to have a function wrapper at /$bunfs/cli.js', + stdout: '', + timedOut: false, + }; + const note = formatRollbackNote({ kind: 'smoke-failed', smoke }); + assert.match(note, /corrupted the binary/); + assert.match(note, /exit 1/); + assert.match(note, /CommonJS module/); + assert.match(note, /restored pristine/); + assert.match(note, /Brand theme \+ prompt overlays disabled/); +}); + +test('formatRollbackNote: smoke-failed with timeout', () => { + const smoke: SmokeTestResult = { + ok: false, + status: null, + signal: null, + stderr: '', + stdout: '', + timedOut: true, + }; + const note = formatRollbackNote({ kind: 'smoke-failed', smoke }); + assert.match(note, /binary hung/); +}); + +test('formatRollbackNote: smoke-failed with signal', () => { + const smoke: SmokeTestResult = { + ok: false, + status: null, + signal: 'SIGSEGV', + stderr: '', + stdout: '', + timedOut: false, + }; + const note = formatRollbackNote({ kind: 'smoke-failed', smoke }); + assert.match(note, /killed by SIGSEGV/); +}); + +test('formatRollbackNote: tweakcc-failed with output', () => { + const note = formatRollbackNote({ + kind: 'tweakcc-failed', + output: 'line one\nfailed to extract claude.js from native installation\nbye', + }); + assert.match(note, /tweakcc failed/); + assert.match(note, /failed to extract/); +}); + +test('formatRollbackNote: tweakcc-failed with empty output', () => { + const note = formatRollbackNote({ kind: 'tweakcc-failed', output: '' }); + assert.match(note, /tweakcc failed \(no output\)/); +}); diff --git a/test/core/tweakcc-windows-async-exec.test.ts b/test/core/tweakcc-windows-async-exec.test.ts deleted file mode 100644 index 6a7ebf3..0000000 --- a/test/core/tweakcc-windows-async-exec.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { runTweakccAsync } from '../../src/core/tweakcc.js'; -import { cleanup, makeTempDir } from '../helpers/index.js'; - -test('runTweakccAsync can execute npx.cmd on Windows', { skip: process.platform !== 'win32' }, async () => { - const stubBin = makeTempDir(); - const tweakDir = makeTempDir(); - const prevPath = process.env.PATH; - - try { - // Use a stub npx.cmd so the test stays offline and deterministic. - const stubNpx = path.join(stubBin, 'npx.cmd'); - fs.writeFileSync(stubNpx, '@echo off\r\necho STUB_NPX_ASYNC\r\nexit /b 0\r\n', { encoding: 'utf8' }); - - process.env.PATH = `${stubBin}${path.delimiter}${prevPath || ''}`; - - const result = await runTweakccAsync(tweakDir, 'C:\\fake\\claude.exe', 'pipe'); - assert.equal(result.status, 0); - assert.match(result.stdout ?? '', /STUB_NPX_ASYNC/i); - } finally { - process.env.PATH = prevPath; - cleanup(stubBin); - cleanup(tweakDir); - } -}); diff --git a/test/core/tweakcc-windows-exec.test.ts b/test/core/tweakcc-windows-exec.test.ts deleted file mode 100644 index 5b9d41d..0000000 --- a/test/core/tweakcc-windows-exec.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { runTweakcc } from '../../src/core/tweakcc.js'; -import { cleanup, makeTempDir } from '../helpers/index.js'; - -test('runTweakcc can execute npx.cmd on Windows', { skip: process.platform !== 'win32' }, () => { - const stubBin = makeTempDir(); - const tweakDir = makeTempDir(); - const prevPath = process.env.PATH; - - try { - // Use a stub npx.cmd so the test stays offline and deterministic. - const stubNpx = path.join(stubBin, 'npx.cmd'); - fs.writeFileSync(stubNpx, '@echo off\r\necho STUB_NPX\r\nexit /b 0\r\n', { encoding: 'utf8' }); - - process.env.PATH = `${stubBin}${path.delimiter}${prevPath || ''}`; - - const result = runTweakcc(tweakDir, 'C:\\fake\\claude.exe', 'pipe'); - assert.equal(result.status, 0); - assert.match(result.stdout ?? '', /STUB_NPX/i); - } finally { - process.env.PATH = prevPath; - cleanup(stubBin); - cleanup(tweakDir); - } -}); diff --git a/test/core/update-rebuild.test.ts b/test/core/update-rebuild.test.ts index 9d38a3d..1fcb166 100644 --- a/test/core/update-rebuild.test.ts +++ b/test/core/update-rebuild.test.ts @@ -41,6 +41,7 @@ const createContext = (rootDir: string, binDir: string, opts: UpdateContext['opt resolvedBin: binDir, variantDir, nativeDir, + unpackedDir: `${variantDir}/unpacked`, }, prefs: { resolvedClaudeVersion: 'stable', diff --git a/test/helpers/bun-fixture.ts b/test/helpers/bun-fixture.ts new file mode 100644 index 0000000..aea54e6 --- /dev/null +++ b/test/helpers/bun-fixture.ts @@ -0,0 +1,262 @@ +/** + * Synthetic Bun standalone-binary fixture builder. + * + * Produces in-memory buffers that emulate the StandaloneModuleGraph layout + * for ELF/Mach-O/PE so the extractor can be unit-tested without shipping + * real bun-compiled binaries. + */ + +import { Buffer } from 'node:buffer'; + +export interface FixtureModule { + name: string; + content: Buffer | string; + sourcemap?: Buffer | string; + bytecode?: Buffer | string; + encoding?: number; + loader?: number; + format?: number; + side?: number; +} + +export interface FixtureOptions { + platform: 'elf' | 'macho' | 'pe'; + moduleStructSize: 36 | 52; + modules: FixtureModule[]; + entryPointId?: number; + flags?: number; + /** For Mach-O: include LC_CODE_SIGNATURE marker. */ + withCodeSignature?: boolean; + /** For Mach-O: bytes of leading code-signature padding to insert AFTER trailer (simulating macOS layout). */ + trailingPadding?: number; +} + +const TRAILER = Buffer.from('\n---- Bun! ----\n'); +const OFFSETS_SIZE = 32; + +const toBuf = (v: Buffer | string | undefined): Buffer => { + if (!v) return Buffer.alloc(0); + return typeof v === 'string' ? Buffer.from(v, 'utf8') : v; +}; + +const writeStringPointer = (buf: Buffer, base: number, offset: number, length: number) => { + buf.writeUInt32LE(offset, base); + buf.writeUInt32LE(length, base + 4); +}; + +interface RawBytesAndTable { + rawBytes: Buffer; + byteCount: number; + modulesOff: number; + modulesLen: number; +} + +const buildRawBytesAndTable = (opts: FixtureOptions): RawBytesAndTable => { + const moduleStructSize = opts.moduleStructSize; + const flagsBase = moduleStructSize === 36 ? 32 : 48; + + // Pack the data region: each module contributes (name) + (content) + (sourcemap?) + (bytecode?). + type PackedModule = { + nameOff: number; + nameLen: number; + contOff: number; + contLen: number; + smapOff: number; + smapLen: number; + bcOff: number; + bcLen: number; + flags: number[]; + }; + + const dataChunks: Buffer[] = []; + let dataCursor = 0; + const packed: PackedModule[] = []; + + const append = (b: Buffer) => { + const off = dataCursor; + dataChunks.push(b); + dataCursor += b.length; + return off; + }; + + for (const mod of opts.modules) { + const nameBuf = Buffer.from(mod.name, 'utf8'); + const contBuf = toBuf(mod.content); + const smapBuf = toBuf(mod.sourcemap); + const bcBuf = toBuf(mod.bytecode); + const nameOff = append(nameBuf); + const contOff = append(contBuf); + const smapOff = smapBuf.length > 0 ? append(smapBuf) : 0; + const bcOff = bcBuf.length > 0 ? append(bcBuf) : 0; + packed.push({ + nameOff, + nameLen: nameBuf.length, + contOff, + contLen: contBuf.length, + smapOff, + smapLen: smapBuf.length, + bcOff, + bcLen: bcBuf.length, + flags: [ + mod.encoding ?? 2, // utf8 + mod.loader ?? 1, // js + mod.format ?? 1, // esm + mod.side ?? 0, // server + ], + }); + } + + // Module table follows the data region. + const modulesOff = dataCursor; + const modulesLen = packed.length * moduleStructSize; + const tableBuf = Buffer.alloc(modulesLen); + for (let i = 0; i < packed.length; i += 1) { + const base = i * moduleStructSize; + const m = packed[i]; + writeStringPointer(tableBuf, base, m.nameOff, m.nameLen); + writeStringPointer(tableBuf, base + 8, m.contOff, m.contLen); + writeStringPointer(tableBuf, base + 16, m.smapOff, m.smapLen); + writeStringPointer(tableBuf, base + 24, m.bcOff, m.bcLen); + // For v52, bytes 32..47 are extra StringPointers we don't need to populate (zeros are fine). + tableBuf.writeUInt8(m.flags[0], base + flagsBase); + tableBuf.writeUInt8(m.flags[1], base + flagsBase + 1); + tableBuf.writeUInt8(m.flags[2], base + flagsBase + 2); + tableBuf.writeUInt8(m.flags[3], base + flagsBase + 3); + } + dataChunks.push(tableBuf); + dataCursor += tableBuf.length; + + const rawBytes = Buffer.concat(dataChunks); + return { + rawBytes, + byteCount: rawBytes.length, + modulesOff, + modulesLen, + }; +}; + +const buildOffsetsStruct = (info: RawBytesAndTable, entryPointId: number, flags: number): Buffer => { + const offsets = Buffer.alloc(OFFSETS_SIZE); + offsets.writeBigUInt64LE(BigInt(info.byteCount), 0); + offsets.writeUInt32LE(info.modulesOff, 8); + offsets.writeUInt32LE(info.modulesLen, 12); + offsets.writeUInt32LE(entryPointId, 16); + // exec_argv StringPointer at +20/+24 (zeros) + offsets.writeUInt32LE(flags, 28); + return offsets; +}; + +const buildElfBinary = (opts: FixtureOptions): { buf: Buffer; expected: { dataStart: number } } => { + const elfHeader = Buffer.alloc(64); + elfHeader[0] = 0x7f; + elfHeader[1] = 0x45; + elfHeader[2] = 0x4c; + elfHeader[3] = 0x46; + // The rest can stay zero - we only care about magic for detection. + + const tableInfo = buildRawBytesAndTable(opts); + const offsets = buildOffsetsStruct(tableInfo, opts.entryPointId ?? 0, opts.flags ?? 0); + const buf = Buffer.concat([elfHeader, tableInfo.rawBytes, offsets, TRAILER]); + return { buf, expected: { dataStart: elfHeader.length } }; +}; + +const buildMachoBinary = ( + opts: FixtureOptions +): { buf: Buffer; expected: { dataStart: number; sectionOffset: number } } => { + // Synthetic Mach-O 64 header: magic + ncmds/sizeofcmds populated only when + // we add load commands, plus a synthetic section_64 header located in the + // first 8 KB. The extractor's read-only path only needs the sectname/segname + // pattern, but the resize path's formal load-command walker needs the + // mach_header_64 counts to be honest. + const header = Buffer.alloc(4096); + header.writeUInt32LE(0xfeedfacf, 0); // MH_MAGIC_64 + + // Place LC_CODE_SIGNATURE (cmd=0x1d, cmdsize=16, dataoff=0, datasize=0) + // immediately after the 32-byte mach_header_64 so the formal walker finds it + // via ncmds/sizeofcmds. The existing heuristic scanner also finds it because + // it sweeps the whole header region every 4 bytes. + const MACH_HEADER_64_SIZE = 32; + const LC_CODE_SIGNATURE = 0x1d; + if (opts.withCodeSignature) { + header.writeUInt32LE(1, 16); // ncmds + header.writeUInt32LE(16, 20); // sizeofcmds + header.writeUInt32LE(LC_CODE_SIGNATURE, MACH_HEADER_64_SIZE); + header.writeUInt32LE(16, MACH_HEADER_64_SIZE + 4); // cmdsize + // dataoff/datasize at +8/+12 stay zero - we don't emit a real signature blob. + } + + // Place a section_64 header at offset 256. + const sectionHeaderOff = 256; + // sectname __bun\0 padded to 16 + Buffer.from('__bun\0').copy(header, sectionHeaderOff); + // segname __BUN at +16 + Buffer.from('__BUN\0').copy(header, sectionHeaderOff + 16); + + const tableInfo = buildRawBytesAndTable(opts); + // sectionOffset is where the Mach-O section_64 payload starts in the file: + // immediately after `header`. The first 8 bytes of that payload are a u64 LE + // length prefix; the parser does sectionOffset + 8 to skip it. + const sectionOffset = header.length; + const sectionDataLen = tableInfo.rawBytes.length + OFFSETS_SIZE + TRAILER.length; + // size at +40 (u64 LE), offset at +48 (u32 LE) + header.writeBigUInt64LE(BigInt(sectionDataLen), sectionHeaderOff + 40); + header.writeUInt32LE(sectionOffset, sectionHeaderOff + 48); + + // Section payload: 8-byte u64 size header, then rawBytes, offsets, trailer. + const sectionSizeHeader = Buffer.alloc(8); + sectionSizeHeader.writeBigUInt64LE(BigInt(tableInfo.rawBytes.length), 0); + const offsets = buildOffsetsStruct(tableInfo, opts.entryPointId ?? 0, opts.flags ?? 0); + const padding = opts.trailingPadding ? Buffer.alloc(opts.trailingPadding) : Buffer.alloc(0); + + const buf = Buffer.concat([header, sectionSizeHeader, tableInfo.rawBytes, offsets, TRAILER, padding]); + return { + buf, + expected: { dataStart: sectionOffset + 8, sectionOffset }, + }; +}; + +const buildPeBinary = ( + opts: FixtureOptions +): { buf: Buffer; expected: { dataStart: number; pointerToRawData: number } } => { + // Minimal-but-valid PE skeleton: DOS header (64) -> e_lfanew points to NT headers. + const dos = Buffer.alloc(64); + dos.writeUInt16LE(0x5a4d, 0); // 'MZ' + const peOff = 0x80; + dos.writeUInt32LE(peOff, 0x3c); + + const ntPrefix = Buffer.alloc(peOff - dos.length); + // PE\0\0 + COFF header(20) + optional header (we'll size it as 0 for simplicity). + const coff = Buffer.alloc(24); // PE sig (4) + FileHeader (20) + coff.writeUInt32LE(0x00004550, 0); + coff.writeUInt16LE(1, 6); // NumberOfSections = 1 + coff.writeUInt16LE(0, 20); // SizeOfOptionalHeader = 0 + + // Section header (40 bytes): name '.bun' padded, then sizes/offsets we care about. + const sectionHeader = Buffer.alloc(40); + Buffer.from('.bun\0').copy(sectionHeader, 0); + + const headerBlob = Buffer.concat([dos, ntPrefix, coff, sectionHeader]); + + // Section payload starts at PointerToRawData. Place it immediately after the headers. + const tableInfo = buildRawBytesAndTable(opts); + const pointerToRawData = headerBlob.length; + const sizeOfRawData = tableInfo.rawBytes.length + OFFSETS_SIZE + TRAILER.length; + // SizeOfRawData at section header +16, PointerToRawData at +20. + headerBlob.writeUInt32LE(sizeOfRawData, peOff + 24 + 0 * 40 + 16); + headerBlob.writeUInt32LE(pointerToRawData, peOff + 24 + 0 * 40 + 20); + + const offsets = buildOffsetsStruct(tableInfo, opts.entryPointId ?? 0, opts.flags ?? 0); + const buf = Buffer.concat([headerBlob, tableInfo.rawBytes, offsets, TRAILER]); + return { buf, expected: { dataStart: pointerToRawData, pointerToRawData } }; +}; + +export type FixtureBuildResult = + | { platform: 'elf'; buf: Buffer; expected: { dataStart: number } } + | { platform: 'macho'; buf: Buffer; expected: { dataStart: number; sectionOffset: number } } + | { platform: 'pe'; buf: Buffer; expected: { dataStart: number; pointerToRawData: number } }; + +export const buildBunFixture = (opts: FixtureOptions): FixtureBuildResult => { + if (opts.platform === 'elf') return { platform: 'elf', ...buildElfBinary(opts) }; + if (opts.platform === 'macho') return { platform: 'macho', ...buildMachoBinary(opts) }; + return { platform: 'pe', ...buildPeBinary(opts) }; +}; diff --git a/test/helpers/mock-core.ts b/test/helpers/mock-core.ts index e3dfa4b..a5906aa 100644 --- a/test/helpers/mock-core.ts +++ b/test/helpers/mock-core.ts @@ -7,7 +7,6 @@ export interface MockCoreCalls { create: Array<{ name: string; providerKey: string; noTweak?: boolean }>; update: Array<{ root: string; name: string }>; - tweak: Array<{ root: string; name: string }>; remove: Array<{ root: string; name: string }>; doctor: Array<{ root: string; bin: string }>; } @@ -16,7 +15,6 @@ export const makeCore = () => { const calls: MockCoreCalls = { create: [], update: [], - tweak: [], remove: [], doctor: [], }; @@ -88,9 +86,6 @@ export const makeCore = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return { meta: { name } as any, tweakResult: null }; }, - tweakVariant: (root: string, name: string) => { - calls.tweak.push({ root, name }); - }, removeVariant: (root: string, name: string) => { calls.remove.push({ root, name }); }, diff --git a/test/tweakcc-error.test.ts b/test/tweakcc-error.test.ts deleted file mode 100644 index fff47cf..0000000 --- a/test/tweakcc-error.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { formatTweakccFailure, isTweakccNativeExtractionFailure } from '../src/core/errors.js'; - -const assertNativeHint = (msg: string) => { - assert.ok(msg.toLowerCase().includes('tweakcc')); - assert.ok(msg.toLowerCase().includes('node-lief')); - assert.ok(msg.toLowerCase().includes('--no-tweak')); -}; - -test('formatTweakccFailure maps classic native extraction errors', () => { - const msg = formatTweakccFailure('Error: Could not extract JS from native binary: /tmp/claude'); - assertNativeHint(msg); -}); - -test('formatTweakccFailure maps tweakcc v4 native extraction errors', () => { - const msg = formatTweakccFailure('Error: Failed to extract claude.js from native installation'); - assertNativeHint(msg); -}); - -test('isTweakccNativeExtractionFailure detects native extraction failures', () => { - assert.equal(isTweakccNativeExtractionFailure('Error: Could not extract JS from native binary: /tmp/claude'), true); - assert.equal(isTweakccNativeExtractionFailure('Error: Failed to extract claude.js from native installation'), true); - assert.equal(isTweakccNativeExtractionFailure('Error: something else went wrong'), false); -});