diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93963e9..12f5c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -518,6 +518,51 @@ jobs: [ "$fail" -eq 0 ] || exit 1 echo "Platform integration smoke OK" + - name: Self-update smoke (offset-shift regression) + shell: bash + run: | + set -eu + FAKE_HOME="$(mktemp -d)" + INSTALL_DIR="$(mktemp -d)" + export HOME="$FAKE_HOME" + export MEMORY_HIVE_DIR="$INSTALL_DIR" + export MEMORY_HIVE_REPO="$(pwd)" + mkdir -p "$FAKE_HOME/.claude" + sh install.sh < /dev/null > /dev/null 2>&1 + + # Build a mutated upstream whose install.sh / memory-hive / + # update.sh are thousands of bytes longer — every later byte + # offset shifts. Updating by executing the INSTALLED installer + # while it copies the longer copy over itself used to corrupt the + # running interpreter ("syntax error near unexpected token `do'"). + MUT="$(mktemp -d)" + cp -R "$(pwd)/." "$MUT/" + for f in install.sh memory-hive update.sh; do + python3 - "$MUT/$f" <<'PYPAD' + import sys + path = sys.argv[1] + lines = open(path).readlines() + pad = ["# pad-for-offset-shift %04d\n" % i for i in range(200)] + lines[1:1] = pad + open(path, "w").writelines(lines) + PYPAD + done + + # The dangerous invocation: the installed installer replacing + # itself with the longer upstream copy while executing. + MEMORY_HIVE_SYNC=1 MEMORY_HIVE_REPO="$MUT" sh "$INSTALL_DIR/install.sh" < /dev/null > /tmp/mh-selfupdate.log 2>&1 \ + || { echo "FAIL: self-update crashed"; tail -20 /tmp/mh-selfupdate.log; exit 1; } + grep -q "pad-for-offset-shift" "$INSTALL_DIR/install.sh" || { echo "FAIL: install.sh not refreshed"; exit 1; } + grep -q "pad-for-offset-shift" "$INSTALL_DIR/memory-hive" || { echo "FAIL: memory-hive not refreshed"; exit 1; } + sh "$INSTALL_DIR/memory-hive" status > /dev/null || { echo "FAIL: updated CLI broken"; exit 1; } + + # And the update.sh wrapper (temp-copy execution) survives the + # reverse shift back to the shorter upstream copy. + sh "$INSTALL_DIR/update.sh" < /dev/null > /tmp/mh-selfupdate2.log 2>&1 \ + || { echo "FAIL: update.sh wrapper crashed"; tail -20 /tmp/mh-selfupdate2.log; exit 1; } + sh "$INSTALL_DIR/memory-hive" status > /dev/null || { echo "FAIL: CLI broken after wrapper update"; exit 1; } + echo "Self-update smoke OK" + - name: Platform integration smoke — opt-out shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index c9cb3c5..6860ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.6.1] — 2026-06-12 — `Self-update no longer eats itself` + +### Fixed + +- `memory-hive update` could crash mid-run with phantom syntax errors + ("syntax error near unexpected token") and leave a half-applied + update. Cause: the installer copied `install.sh` and `memory-hive` + over themselves in place while those very files were executing (the + PATH shim is a symlink to the same inode), so the running interpreter + read the new, longer file at old byte offsets. The recent releases + grew both files enough to make this near-deterministic. Fixes: + - all helper installs and hook renders now stage to a temp file and + `mv` into place — rename keeps the old inode readable for running + processes; + - `update.sh` executes the installed installer from a temp copy, + never from the path being refreshed; + - new CI step installs, then self-updates from a deliberately + offset-shifted upstream copy and asserts the run completes and the + refreshed CLI works (both the direct and the `update.sh` wrapper + paths). + Affected machines recover with one + `curl -fsSL .../install.sh | sh` (or a second `memory-hive update`, + which is byte-identical and therefore safe); agent silos were never + touched — the crash happened after the preserve/restore step. + ## [1.6.0] — 2026-06-12 — `Cursor falls in line` ### Added diff --git a/install.sh b/install.sh index 63f2bb6..999bd3f 100755 --- a/install.sh +++ b/install.sh @@ -209,11 +209,20 @@ fi # Install the helper scripts into the install dir so users can run them # locally without a curl round-trip. These are tools (not user content) and # we always keep them current with upstream. +# +# Stage + rename, never cp in place: on the update path THIS process IS +# $INSTALL_DIR/install.sh, and the CLI that launched it is (or is symlinked +# to) $INSTALL_DIR/memory-hive. An in-place cp rewrites the bytes under the +# running interpreter, which then reads the new file at old offsets and +# dies with phantom syntax errors. rename() just swaps the directory +# entry — the old inode stays readable until the process exits. for helper in create-agent.sh update.sh install.sh check-compliance.sh memory-hive memory_hive_recall.py memory_hive_mcp.py; do _src="$TMP_DIR/memory-hive/$helper" [ -f "$_src" ] || continue - cp "$_src" "$INSTALL_DIR/$helper" - chmod +x "$INSTALL_DIR/$helper" 2>/dev/null || true + _hstage="$INSTALL_DIR/$helper.memhive.$$" + cp "$_src" "$_hstage" || { rm -f "$_hstage"; die "Failed to stage $helper"; } + chmod +x "$_hstage" 2>/dev/null || true + mv "$_hstage" "$INSTALL_DIR/$helper" || { rm -f "$_hstage"; die "Failed to install $helper"; } done # Install a bare `memory-hive` command into the user's existing PATH when @@ -700,10 +709,16 @@ if [ -d "$_hooks_src_dir" ]; then for _hk in "$_hooks_src_dir"/*.sh; do [ -f "$_hk" ] || continue _hk_dst="$INSTALL_DIR/hooks/$(basename "$_hk")" + # Stage + rename for the same reason as the helper loop: a hook + # could be mid-execution while an update refreshes it. + _hk_stage="$_hk_dst.memhive.$$" if sed -e "s|\${HIVE_DIR}|$_hive_dir_escaped|g" \ -e "s|\${INSTALL_DIR}|$_install_dir_escaped|g" \ - "$_hk" > "$_hk_dst" 2>/dev/null; then - chmod +x "$_hk_dst" 2>/dev/null || true + "$_hk" > "$_hk_stage" 2>/dev/null; then + chmod +x "$_hk_stage" 2>/dev/null || true + mv "$_hk_stage" "$_hk_dst" 2>/dev/null || rm -f "$_hk_stage" + else + rm -f "$_hk_stage" 2>/dev/null || true fi done fi diff --git a/update.sh b/update.sh index 44281d2..9f69772 100755 --- a/update.sh +++ b/update.sh @@ -28,8 +28,22 @@ REMOTE_INSTALLER="https://raw.githubusercontent.com/TJCurnutte/memory-hive/main/ # Prefer the locally-installed install.sh so offline/private forks still work; # fall back to curl from GitHub. In both cases, set MEMORY_HIVE_SYNC=1 so # install.sh knows this is an update and should refresh shared content. +# +# CRITICAL: run the installer from a TEMP COPY, never from +# $INSTALL_DIR/install.sh directly — the installer refreshes that very file +# mid-run, and executing a file while it is replaced corrupts the running +# interpreter (phantom "syntax error near unexpected token"). if [ -f "$INSTALL_DIR/install.sh" ]; then - MEMORY_HIVE_SYNC=1 MEMORY_HIVE_DIR="$INSTALL_DIR" sh "$INSTALL_DIR/install.sh" + _tmp_installer="$(mktemp "${TMPDIR:-/tmp}/memory-hive-install.XXXXXX")" \ + || { printf 'ERROR: mktemp failed\n' >&2; exit 1; } + cp "$INSTALL_DIR/install.sh" "$_tmp_installer" + if MEMORY_HIVE_SYNC=1 MEMORY_HIVE_DIR="$INSTALL_DIR" sh "$_tmp_installer"; then + _rc=0 + else + _rc=$? + fi + rm -f "$_tmp_installer" + exit "$_rc" elif command -v curl >/dev/null 2>&1; then MEMORY_HIVE_SYNC=1 MEMORY_HIVE_DIR="$INSTALL_DIR" \ sh -c "$(curl -fsSL "$REMOTE_INSTALLER")"