Skip to content

Commit 65cbeec

Browse files
authored
feat(perps): decouple agentic cache fingerprint from project EAS fingerprint (MetaMask#30569)
## **Description** Adds `scripts/perps/agentic/lib/compute-cache-fp.js`, an agentic-local native-build fingerprint, and switches the build cache in `bc_fingerprint` to use it instead of `scripts/generate-fingerprint.js`. The project-wide fingerprint script and `fingerprint.config.js` are untouched, so EAS Build, EAS Update, and the OTA fingerprint guard in `nightly-ota-updates.md` keep their existing semantics. **Why:** `--mode auto`'s shared cache (MetaMask#30565) was keyed off the same fingerprint EAS/OTA depend on, which conservatively hashes build outputs (`ios/build/`, `.gradle/`, IDE `xcuserdata`, env-populated `xcconfig`/`google-services.json`). Those paths diverge per worktree, so two slots on the same commit hashed to different keys and never shared a cached `.app` — the cross-worktree benefit the cache was designed for never landed. The new fingerprint uses the same `extraSources` (so anything that genuinely affects the binary still participates) but ignores the per-worktree noise paths. Verified across three worktrees on the same commit (`c06187a24c`, all on `main`): ``` mm-1: c7fe9e2161109d4ff2e092e068821a2e9984aac5 mm-5: c7fe9e2161109d4ff2e092e068821a2e9984aac5 mm-6: c7fe9e2161109d4ff2e092e068821a2e9984aac5 ``` Identical → next `--mode auto` dispatch on any of these slots will install the cached artifact from whichever slot built first. ## **Changelog** CHANGELOG entry: null ## **Related issues** Follows MetaMask#30565. ## **Manual testing steps** ```gherkin Feature: Agentic cache fingerprint decoupled from project fingerprint Scenario: Project fingerprint unchanged When I run "node scripts/generate-fingerprint.js" Then the hash is the same as on `main` before this PR And EAS / OTA tools that depend on it are unaffected Scenario: Agentic cache fingerprint ignores build artifacts Given a worktree at any state When I capture the agentic fingerprint And I write a poison file under `ios/build/` And I capture the agentic fingerprint again Then the two fingerprints are identical Scenario: Cross-worktree fingerprint match on same commit Given two worktrees (mm-5, mm-6) at the same git commit When I compute the agentic fingerprint in each Then they match And `--mode auto` on the second slot installs from the cached artifact stored by the first ``` Programmatic check the test suite runs: ```bash bash scripts/perps/agentic/lib/test-build-cache.sh ``` New section "agentic fp ignores build artifacts" verifies the ignorePaths actually take effect. ## **Screenshots/Recordings** N/A — script-only change, no UI surface. ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable (new "agentic fp ignores build artifacts" assertion in `test-build-cache.sh`) - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable (file-level rationale in `compute-cache-fp.js`) - [x] I've applied the right labels on the PR #### Performance checks (if applicable) - [ ] I've tested on Android — N/A (script-only) - [ ] I've tested with a power user scenario — N/A - [ ] I've instrumented key operations with Sentry traces — N/A (developer tooling) ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR. - [ ] I confirm that this PR addresses all acceptance criteria. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the cache key used for agentic preflight native build reuse; incorrect ignore/hashing boundaries could cause stale or mismatched cached binaries across worktrees. > > **Overview** > Decouples the agentic shared native build cache fingerprint from the repo-wide EAS/OTA fingerprint by introducing `scripts/perps/agentic/lib/compute-cache-fp.js` and switching `bc_fingerprint` to use it. > > The new fingerprint inherits `fingerprint.config.js` `extraSources` (plus explicitly hashes `app/core/InpageBridgeWeb3.js`) while adding `ignorePaths` for per-worktree build outputs (e.g., `ios/build`, Xcode `xcuserdata`, `.gradle`, NDK `.cxx`) so cache artifacts can be shared across parallel worktrees. > > Updates agentic docs to describe the new keying behavior and extends `test-build-cache.sh` with boundary tests ensuring ignored paths don’t shift the fingerprint while binary-affecting sources still do (and adjusts the fast-mode failure test to reference the new script). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 32aa686. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6018bf7 commit 65cbeec

4 files changed

Lines changed: 148 additions & 3 deletions

File tree

scripts/perps/agentic/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ Compound: `{ all: [...] }`, `{ any: [...] }`, `{ none: [...] }`.
224224
| `rebuild-native` | no | yes (no `--repo-update`) | yes | no |
225225
| `clean` (legacy `--clean`) | yes | yes with `--repo-update` | yes | no (writes only) |
226226

227-
Cache lives in `$MM_BUILD_CACHE_DIR` (default `~/Library/Caches/mm-mobile-builds` on macOS, `~/.cache/mm-mobile-builds` on Linux), keyed by `@expo/fingerprint` hash. Parallel worktrees at the same fingerprint share one artifact through a per-fingerprint mutex: Linux uses `flock(1)` (auto-released by the kernel on process death); macOS, where `flock` is not in base, uses an atomic `mkdir <fp>.lock.d` fallback that is released by the script's `EXIT` trap. If a script is killed with `kill -9` between `mkdir` and the trap, the mutex dir can be left behind — delete it manually under `$MM_BUILD_CACHE_DIR/<plat>/`. Override retention with `BUILD_CACHE_RETAIN=N` (default 5 per platform).
227+
Cache lives in `$MM_BUILD_CACHE_DIR` (default `~/Library/Caches/mm-mobile-builds` on macOS, `~/.cache/mm-mobile-builds` on Linux), keyed by an agentic `@expo/fingerprint` hash computed by `scripts/perps/agentic/lib/compute-cache-fp.js`. The agentic fingerprint *extends* the project-wide `fingerprint.config.js` (which EAS Build and OTA still consume unchanged) with additional `ignorePaths` for per-worktree build artifacts that don't influence binary semantics (`ios/build/`, `.gradle/`, Xcode `xcuserdata`, NDK `.cxx`, etc.). Binary-affecting inputs — env-populated `xcconfig`, `google-services.json`, and the bundled `InpageBridgeWeb3.js` — stay hashed, so the cache only converges across worktrees when those inputs match. Parallel worktrees at the same fingerprint share one artifact through a per-fingerprint mutex: Linux uses `flock(1)` (auto-released by the kernel on process death); macOS, where `flock` is not in base, uses an atomic `mkdir <fp>.lock.d` fallback that is released by the script's `EXIT` trap. If a script is killed with `kill -9` between `mkdir` and the trap, the mutex dir can be left behind — delete it manually under `$MM_BUILD_CACHE_DIR/<plat>/`. Override retention with `BUILD_CACHE_RETAIN=N` (default 5 per platform).
228228

229229
Invoke directly:
230230

scripts/perps/agentic/lib/build-cache.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ bc_fingerprint() {
7878
fi
7979
fi
8080
local fp
81-
fp=$(node scripts/generate-fingerprint.js 2>/dev/null || true)
81+
# Use the agentic fingerprint. It extends the project's fingerprint.config.js
82+
# (so EAS/OTA inputs still participate) with additional ignorePaths for
83+
# per-worktree build outputs. See compute-cache-fp.js for the rationale.
84+
fp=$(node scripts/perps/agentic/lib/compute-cache-fp.js 2>/dev/null || true)
8285
if [ -z "$fp" ]; then
8386
return 1
8487
fi
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env node
2+
// compute-cache-fp.js — agentic-local native-build fingerprint for the
3+
// shared build cache.
4+
//
5+
// Relation to the project fingerprint:
6+
// The repo-wide `scripts/generate-fingerprint.js` is consumed by EAS Build,
7+
// EAS Update, and the OTA fingerprint guard in
8+
// `docs/nightly-ota-updates.md`. Its `fingerprint.config.js` deliberately
9+
// errs on the side of hashing too much — every local build artifact that
10+
// could conceivably influence the produced binary participates in the key
11+
// so a hash collision can never reuse a build whose inputs we cannot
12+
// vouch for.
13+
//
14+
// `@expo/fingerprint`'s `createFingerprintAsync(projectRoot, options)`
15+
// loads `fingerprint.config.js` and applies caller options with these
16+
// semantics (per `@expo/fingerprint` 0.15.x):
17+
// - `extraSources`: caller OVERRIDES the config's list when set.
18+
// - `ignorePaths`: caller is MERGED with the config's list.
19+
// To stay in sync with future edits to `fingerprint.config.js`, we
20+
// `require()` it directly and spread its lists into our options. Our
21+
// added ignorePaths cover per-worktree dev/build artifacts that don't
22+
// affect binary semantics (compile outputs, IDE state, NDK cache,
23+
// per-machine `.xcode.env.local`).
24+
// Binary-affecting inputs — env-populated xcconfig, `google-services.json`,
25+
// the bridge source — stay hashed. The cache only converges across
26+
// worktrees when those inputs match, which is the correct behaviour.
27+
28+
const fp = require('@expo/fingerprint');
29+
// Import the project's config so future additions to its extraSources
30+
// automatically flow into the agentic fingerprint. Using require here
31+
// (vs. literally copying the list) means a new entry in
32+
// `fingerprint.config.js` cannot silently leave the agentic cache
33+
// behind.
34+
const projectConfig = require('../../../../fingerprint.config.js');
35+
36+
const options = {
37+
// Inherit the project's extraSources and append the runtime JS bridge
38+
// source. The bridge is copied into android/ios assets at build time
39+
// and embedded in the .jsbundle, so its content affects binary output.
40+
extraSources: [
41+
...(projectConfig.extraSources || []),
42+
{
43+
type: 'file',
44+
filePath: 'app/core/InpageBridgeWeb3.js',
45+
reasons: ['Bundled into the runtime JS — affects binary behaviour.'],
46+
},
47+
],
48+
// Per-worktree dev/build state that does not influence the produced
49+
// binary. All are gitignored and regenerated locally; ignoring them
50+
// lets two slots on the same commit with the same source env share a
51+
// cached `.app`/`.apk`.
52+
ignorePaths: [
53+
'ios/build/**',
54+
'ios/.xcode.env.local',
55+
'ios/MetaMask.xcworkspace/xcshareddata/swiftpm/**',
56+
'ios/**/xcuserdata/**',
57+
'android/.gradle/**',
58+
'android/app/.cxx/**',
59+
'android/app/build/**',
60+
// Mirror of app/core/InpageBridgeWeb3.js — already tracked via the
61+
// extraSources entry above; ignore the generated copy so we don't
62+
// double-count it on rebuild.
63+
'android/app/src/main/assets/InpageBridgeWeb3.js',
64+
],
65+
};
66+
67+
fp.createFingerprintAsync(process.cwd(), options)
68+
.then(({ hash }) => {
69+
process.stdout.write(hash);
70+
})
71+
.catch((err) => {
72+
process.stderr.write(`compute-cache-fp: ${err.message}\n`);
73+
process.exit(1);
74+
});

scripts/perps/agentic/lib/test-build-cache.sh

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,74 @@ echo "$out" | grep -qE "Mode:.*clean.*yarn setup" && pass "clean mode header ren
184184
out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --clean --check-only 2>&1 | head -20 || true)
185185
echo "$out" | grep -qE "Mode:.*clean.*yarn setup" && pass "legacy --clean still maps to clean" || fail "legacy --clean broken"
186186

187+
# ─── 10b. Agentic fp respects the safe/unsafe ignorePath boundary ──
188+
# compute-cache-fp.js ignores per-worktree build outputs but MUST keep
189+
# binary-affecting inputs (xcconfig, google-services.json, the bundled
190+
# InpageBridgeWeb3 source) hashed. Verify both halves of that contract.
191+
hdr "agentic fp respects ignorePath boundary"
192+
193+
_capture_fp() {
194+
bc_memo_cleanup 2>/dev/null || true
195+
bc_memo_init
196+
bc_fingerprint 2>/dev/null
197+
}
198+
199+
FP_BASELINE=$(_capture_fp)
200+
[ -n "$FP_BASELINE" ] && pass "baseline fp computed: ${FP_BASELINE:0:12}" \
201+
|| fail "baseline fp empty"
202+
203+
# (a) Poisoning an IGNORED path must NOT change fp.
204+
mkdir -p ios/build
205+
POISON_IGNORED="ios/build/__bc_test_poison_$$.bin"
206+
echo "poison-$RANDOM" > "$POISON_IGNORED"
207+
FP_IGNORED=$(_capture_fp)
208+
rm -f "$POISON_IGNORED"
209+
if [ "$FP_BASELINE" = "$FP_IGNORED" ]; then
210+
pass "ignored ios/build/ poison did NOT shift fp"
211+
else
212+
fail "ignored ios/build/ poison SHIFTED fp (drift): $FP_BASELINE -> $FP_IGNORED"
213+
fi
214+
215+
# Restore-trapped poison helper: backs up the file, layers a temporary
216+
# EXIT trap that restores the file AND re-invokes the suite-level cleanup,
217+
# then restores the original suite-level trap before returning. Ensures
218+
# .agent/build-cache cleanup still runs on early abort inside the helper.
219+
_poison_must_shift_fp() {
220+
local label="$1" path="$2"
221+
if [ ! -f "$path" ]; then
222+
fail "missing $path — cannot run boundary test"
223+
return
224+
fi
225+
local bak="/tmp/__bc_test_$(basename "$path")_$$.bak"
226+
cp "$path" "$bak"
227+
# Capture the suite-level EXIT trap so we can re-install it after.
228+
local prev_trap
229+
prev_trap=$(trap -p EXIT)
230+
trap "cp '$bak' '$path' 2>/dev/null; rm -f '$bak' 2>/dev/null; cleanup" EXIT
231+
echo "// __bc_test_poison_$$ $RANDOM" >> "$path"
232+
local fp_after
233+
fp_after=$(_capture_fp)
234+
cp "$bak" "$path"
235+
rm -f "$bak"
236+
# Restore the suite-level cleanup trap.
237+
eval "${prev_trap:-trap - EXIT}"
238+
if [ "$fp_after" != "$FP_BASELINE" ]; then
239+
pass "$label DID shift fp (${fp_after:0:12})"
240+
else
241+
fail "$label was silently ignored — cache could serve stale binary"
242+
fi
243+
}
244+
245+
# (b) Poisoning a HASHED, binary-affecting path MUST change fp.
246+
_poison_must_shift_fp "InpageBridgeWeb3.js (bridge source)" "app/core/InpageBridgeWeb3.js"
247+
248+
# (c) Poisoning an inherited project extraSource MUST change fp — proves
249+
# the script repeats fingerprint.config.js extraSources correctly.
250+
_poison_must_shift_fp "scripts/setup.mjs (project extraSource)" "scripts/setup.mjs"
251+
252+
# Restore baseline state for the rest of the suite.
253+
_capture_fp >/dev/null
254+
187255
# ─── 11. Memo cleanup refuses inherited / unowned BC_MEMO_DIR ──────
188256
# Across R6/R7/R8/R9 codex flagged five attack shapes against the memo
189257
# directory cleanup. Each scenario sets up a "victim" dir, hands its path
@@ -215,7 +283,7 @@ _memo_attack "R9B: EXIT cleanup on inherited memo" ""
215283
# Codex R2 B3: --mode fast must hard-fail if the fingerprint command can't
216284
# run, instead of silently falling through to the legacy build path.
217285
hdr "preflight --mode fast / fingerprint failure"
218-
FP_SCRIPT="scripts/generate-fingerprint.js"
286+
FP_SCRIPT="scripts/perps/agentic/lib/compute-cache-fp.js"
219287
FP_BACKUP="${FP_SCRIPT}.test-bak-$$"
220288
mv "$FP_SCRIPT" "$FP_BACKUP"
221289
restore_fp() { [ -f "$FP_BACKUP" ] && mv "$FP_BACKUP" "$FP_SCRIPT" 2>/dev/null || true; }

0 commit comments

Comments
 (0)