Files
wehub-resource-sync 3e076d4dd9
Deploy Worker / deploy (push) Failing after 1s
Shellcheck / Check shell scripts (push) Failing after 1s
CI / Build Packages (amd64 - appimage) (push) Has been skipped
CI / Build Packages (amd64 - deb) (push) Has been skipped
CI / Build Packages (amd64 - rpm) (push) Has been skipped
CI / Build Packages (arm64 - appimage) (push) Has been skipped
CI / Build Packages (arm64 - deb) (push) Has been skipped
CI / Test Flags Parsing (push) Failing after 1s
BATS Tests / BATS unit tests (push) Failing after 1s
CI / Build Packages (arm64 - rpm) (push) Has been skipped
CI / Test Build Artifacts (amd64) (push) Has been skipped
CI / Test Build Artifacts (arm64) (push) Has been skipped
CI / Update APT Repository (push) Has been cancelled
CI / Mirror Official .deb to Release (push) Has been cancelled
CI / Update DNF Repository (push) Has been cancelled
CI / Update AUR Package (push) Has been cancelled
CI / Create Release (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:29:21 +08:00

186 lines
8.0 KiB
Bash

#===============================================================================
# Config-write guard (PARKED — not in active_patches). In-band fallback
# for the claude_desktop_config.json wipe class (#768; upstream
# anthropics/claude-code #32345 / #59640 / #63651). The PRIMARY fix is
# launcher-side backup rotation (backup_user_config in
# launcher-common.sh) — patch-zero-clean and broader-coverage; this
# stays hardened and ready to arm if the backup proves insufficient.
# Full rationale + the contrarian review that demoted it:
# docs/learnings/config-wipe-guard.md.
#
# What #768 actually hit (lead with the on-target rule, R3): the
# claude.ai renderer mirrors its grouping/starring stores into
# preferences.epitaxyPrefs on every launch via the AppPreferences
# bridge. A transient IndexedDB hydration failure hydrates those stores
# empty, and the mirror writes the empty state into epitaxyPrefs. R3
# catches exactly that.
#
# Same-class, seen upstream but NOT confirmed in #768: the config
# loader caches the parsed file once at cold start and silently falls
# back to {} on a failed read (inaccessible file, JSON-parse error,
# Zod rejection). A later whole-file write then serializes {} over a
# populated config, stubbing mcpServers / groupings / trusted folders.
# R1/R2 catch that.
#
# R1 top-level keys present on disk but absent from the outgoing
# object are restored (no code path legitimately deletes a
# top-level key, so absence always means "never loaded")
# R2 same rule per preferences.* key
# R3 preferences.epitaxyPrefs: only when EVERY outgoing value is
# deep-empty (the hydration-failure signature — a real session
# carries non-empty numeric view state) restore the non-empty
# values from disk
#
# Restores land on a lazy CLONE of the outgoing object (see the guard
# body), so a wrong R3 restore touches only the bytes on disk, never
# the live config cache — the sticky-trap the review flagged is gone.
#
# Does NOT revive the #400 Object.assign mcpServers merge: CF-1
# (2026-07-03) showed 1.18286.0 deletes entries programmatically, so a
# blind merge resurrects deleted servers. Deletions keep the key
# present, so none of R1-R3 fire on them.
#
# Known blind spots (fail-open, no worse than upstream):
# - Corrupt-JSON cold-start (loader mode 2): the guard's own re-parse
# of the still-corrupt disk file throws, so it writes the stub.
# Acceptable — corrupt bytes hold no recoverable structured data.
# - Persistent read failure (mode 1 not recovered by write time).
# - Deliberate clear-all of every epitaxy key including paneStore
# numerics: indistinguishable from a hydration failure, so R3 would
# restore it (disk-only now, not sticky). Rare; launcher backup is
# the real safety net.
#
# Sourced by: build.sh
# Sourced globals: project_root
# Modifies globals: (none)
#===============================================================================
patch_config_write_guard() {
echo 'Patching config writer to guard against poisoned-cache wipes...'
local index_js='app.asar.contents/.vite/build/index.js'
# Idempotency guard
if grep -q '_cdd_dc' "$index_js"; then
echo ' config-write guard already present (idempotent)'
echo '##############################################################'
return
fi
# Extract variable names from the unique anchor:
# await WRITE_FN(PATH_VAR, CONFIG_VAR), LOGGER.info("Config file written")
local write_fn path_var config_var write_fn_re path_var_re
write_fn=$(grep -oP \
'await \K[$\w]+(?=\([$\w]+,\s*[$\w]+\)\s*,\s*[$\w]+\.info\("Config file written"\))' \
"$index_js")
if [[ -z $write_fn ]]; then
echo 'Tripwire (CFG-1): config-write anchor missing — could not' \
'extract the write function around "Config file written".' \
'Upstream reshaped the config writer; re-derive the guard' \
'before shipping (scripts/patches/config.sh).' >&2
return 1
fi
write_fn_re="${write_fn//\$/\\$}"
path_var=$(grep -oP \
"await ${write_fn_re}\\(\\K[\$\\w]+(?=,\\s*[\$\\w]+\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
"$index_js")
if [[ -z $path_var ]]; then
echo 'Tripwire (CFG-1): could not extract the config path' \
'variable — re-derive the guard before shipping.' >&2
return 1
fi
path_var_re="${path_var//\$/\\$}"
config_var=$(grep -oP \
"await ${write_fn_re}\\(${path_var_re},\\s*\\K[\$\\w]+(?=\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
"$index_js")
if [[ -z $config_var ]]; then
echo 'Tripwire (CFG-1): could not extract the config object' \
'variable — re-derive the guard before shipping.' >&2
return 1
fi
echo " Write fn: $write_fn, path: $path_var, config: $config_var"
if ! WRITE_FN="$write_fn" PATH_VAR="$path_var" CFG_VAR="$config_var" \
node -e "
const fs = require('fs');
const p = 'app.asar.contents/.vite/build/index.js';
const W = process.env.WRITE_FN;
const P = process.env.PATH_VAR;
const C = process.env.CFG_VAR;
let code = fs.readFileSync(p, 'utf8');
const reEsc = (s) => s.replace(/[.*+?\${}()|[\\]\\\\]/g, '\\\\\$&');
// [\$\\w] not \\w: minified identifiers may contain \$
// (docs/learnings/patching-minified-js.md)
const anchorSrc =
'await\\\\s+' + reEsc(W) + '\\\\(' + reEsc(P) + ',\\\\s*' + reEsc(C) +
'\\\\)\\\\s*,\\\\s*[\$\\\\w]+\\\\.info\\\\(\"Config file written\"\\\\)';
const hits = code.match(new RegExp(anchorSrc, 'g')) ?? [];
if (hits.length !== 1) {
console.error(' [FAIL] Config-write anchor matched ' + hits.length +
' times (expected 1)');
process.exit(1);
}
const anchor = new RegExp(anchorSrc);
// Restores are applied to a LAZY CLONE of the outgoing object, never
// to the object itself. arA's config param is a local binding
// captured by the write closure, so reassigning it (CFG=...) feeds the
// clone to the writer while leaving the in-memory config cache (PaA)
// exactly as the renderer set it. That removes the sticky-trap: a
// wrong R3 restore (see the doc's false-positive analysis) affects
// only the bytes on disk, not live session state, and never
// re-materializes into subsequent writes.
//
// _cdd_em: deep-empty — null/[]/{} or an object whose every value is
// deep-empty. Numbers/strings/booleans are never empty, so real view
// state (rowSplit:0.5, version:0) keeps R3 from firing on live data.
const guard =
C + '=(function(_p,_c){try{' +
'var _cdd_dc=JSON.parse(require(\"fs\").readFileSync(_p,\"utf8\"));' +
'if(!_cdd_dc||typeof _cdd_dc!=\"object\"||Array.isArray(_cdd_dc))return _c;' +
'var _cdd_em=function(v){if(v==null)return!0;' +
'if(Array.isArray(v))return v.length===0;' +
'if(typeof v==\"object\"){for(var _cdd_i in v){' +
'if(!_cdd_em(v[_cdd_i]))return!1}return!0}return!1};' +
'var _cdd_o=null,_cdd_cl=function(){' +
'return _cdd_o||(_cdd_o=Object.assign({},_c))};' +
'for(var _cdd_k in _cdd_dc){if(_c[_cdd_k]===void 0)' +
'_cdd_cl()[_cdd_k]=_cdd_dc[_cdd_k]}' +
'var _cdd_dp=_cdd_dc.preferences,_cdd_cp=_c.preferences;' +
'if(_cdd_dp&&typeof _cdd_dp==\"object\"&&_cdd_cp&&' +
'typeof _cdd_cp==\"object\"){' +
'var _cdd_po=null,_cdd_pcl=function(){if(!_cdd_po){' +
'_cdd_po=Object.assign({},_cdd_cp);_cdd_cl().preferences=_cdd_po}' +
'return _cdd_po};' +
'for(var _cdd_p in _cdd_dp){if(_cdd_cp[_cdd_p]===void 0)' +
'_cdd_pcl()[_cdd_p]=_cdd_dp[_cdd_p]}' +
'var _cdd_de=_cdd_dp.epitaxyPrefs,' +
'_cdd_ce=(_cdd_po||_cdd_cp).epitaxyPrefs;' +
'if(_cdd_de&&typeof _cdd_de==\"object\"&&_cdd_ce&&' +
'typeof _cdd_ce==\"object\"&&_cdd_em(_cdd_ce)&&!_cdd_em(_cdd_de)){' +
'var _cdd_eo=Object.assign({},_cdd_ce);' +
'for(var _cdd_g in _cdd_de){if(!_cdd_em(_cdd_de[_cdd_g]))' +
'_cdd_eo[_cdd_g]=_cdd_de[_cdd_g]}' +
'_cdd_pcl().epitaxyPrefs=_cdd_eo}' +
'}' +
'return _cdd_o||_c;' +
'}catch(_cdd_ex){return _c}})(' + P + ',' + C + ')';
code = code.replace(anchor, (m) => guard + ';' + m);
fs.writeFileSync(p, code);
console.log(' [OK] config-write wipe guard injected before config write');
"; then
echo 'Failed to inject config write guard' >&2
cd "$project_root" || exit 1
exit 1
fi
echo '##############################################################'
}