fix(scripts): make the mirror's mode check umask-independent
The previous round widened path_manifest from the exec bit to full permission bits, and that made check-plugin-content-sync fail at pre-push on a pristine tree. hooks/hooks.json is not copied from the bundle -- sync_hooks_json writes it with printf, i.e. at the runtime umask -- while the real side comes from the checkout. On a umask-002 clone the two disagree, 664 vs 644, and no commit can reconcile them because git tracks no non-exec mode. The rule adopted: record a mode for a path this pipeline copies, never for one it writes. A copied path's mode traces to the same checkout on both sides, so comparing it means something; a written path's mode is the writer's umask on one side and the checkout's on the other, which are independent. That is the same rationale the directory exclusion already carried -- what broke was the premise that files are immune. Normalising instead was rejected: pinning the generated side cannot fix a checked-out side that is already 664. The unconditional chmod 644 in reinject_mcp_servers goes for the same reason; writing through the destination inode already closed the original 0600 bug. The mode coverage added for the two plugin.json manifests is removed rather than documented, because it measured nothing on any axis. In check mode the expected side is a cp -a of the real plugin root, so apm rewrites an existing inode and inherits its mode; and a symlinked manifest is copied as a symlink and written straight through, so both sides agreed no matter what. That symlink case is a real hazard -- the re-injection corrupts the link's target -- so it is now asserted directly instead. Also: an unparseable or non-object per-plugin plugin.json killed the manifest walk mid-loop; the source-less-entry guard closed only source: null and let every other malformed value through; the select it backstops was extracted so a test can exercise it independently, which nothing could before; and two more `|| pwd` fallbacks now hard-error -- with a decoy marketplace.json in $PWD, --all derived its plugin list from it. Tests: 63 -> 77 and 23 -> 31 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
This commit is contained in:
@@ -324,21 +324,39 @@ else
|
||||
pass "mcpServers is not an inlined object"
|
||||
fi
|
||||
|
||||
# --- 10b. .github/plugin/plugin.json is world-readable 0644, not mktemp's 0600 ---
|
||||
# --- 10b. The re-injection preserves the manifest's own mode, rather than importing one ---
|
||||
# reinject_mcp_servers used to build its replacement in a `mktemp` file (mode 0600)
|
||||
# and `mv` it over the manifest, carrying 0600 onto a tracked, published file. Git
|
||||
# records only the exec bit, so the demotion survived every commit unnoticed — this
|
||||
# repo's own plugins/bin/.github/plugin/plugin.json really was 0600 on disk while
|
||||
# its five siblings were 0644.
|
||||
#
|
||||
# The fix is `cat "$tmp" >"$dst"`, which keeps the destination inode: the manifest
|
||||
# ends up at whatever mode apm pack gave it a moment earlier, i.e. 0666 & ~umask like
|
||||
# any other freshly created file. So this is asserted across two umasks rather than
|
||||
# against a hardcoded 644 — that is what distinguishes "preserved" from "assigned".
|
||||
# A `mv` of the mktemp yields 600 under both; a `chmod 644` yields 644 under both,
|
||||
# which is the umask dependence that made --check fail on a umask-002 checkout.
|
||||
mode_of() {
|
||||
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
|
||||
}
|
||||
echo ""
|
||||
echo "--- a real sync leaves .github/plugin/plugin.json at mode 644 ---"
|
||||
MODE10="$(stat -c '%a' "$FIXTURE10/.github/plugin/plugin.json" 2>/dev/null \
|
||||
|| stat -f '%Lp' "$FIXTURE10/.github/plugin/plugin.json" 2>/dev/null)"
|
||||
if [[ "$MODE10" == "644" ]]; then
|
||||
pass "mcpServers re-injection leaves the manifest at 644"
|
||||
else
|
||||
fail "mcpServers re-injection left .github/plugin/plugin.json at mode $MODE10, expected 644"
|
||||
fi
|
||||
echo "--- the mcpServers re-injection leaves the manifest at the umask's own file mode ---"
|
||||
for UMASK10B in 022 002; do
|
||||
case "$UMASK10B" in
|
||||
022) EXPECT10B=644 ;;
|
||||
002) EXPECT10B=664 ;;
|
||||
esac
|
||||
FIXTURE10B="$(umask "$UMASK10B"; make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"
|
||||
track "$FIXTURE10B"
|
||||
(umask "$UMASK10B"; bash "$SCRIPT" "$FIXTURE10B" > /dev/null 2>&1)
|
||||
MODE10B="$(mode_of "$FIXTURE10B/.github/plugin/plugin.json")"
|
||||
if [[ "$MODE10B" == "$EXPECT10B" ]]; then
|
||||
pass "under umask $UMASK10B the re-injected manifest is $EXPECT10B (its own mode, not mktemp's 600 and not a hardcoded one)"
|
||||
else
|
||||
fail "under umask $UMASK10B the re-injected manifest is $MODE10B, expected $EXPECT10B"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 11. An empty .mcp.json does not add a redundant mcpServers: {} ---
|
||||
echo ""
|
||||
@@ -782,35 +800,62 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 25. --check sees a mode change on the generated Copilot manifest ---
|
||||
# sync_plugin_manifest diffs that file's CONTENT only, and check_path_modes did not
|
||||
# cover .github/plugin/ at all — so the mode reinject_mcp_servers writes was outside
|
||||
# --check's manifest entirely, and check and sync could disagree about it forever.
|
||||
# --- 25. The generated manifests: --check and a real sync agree about their mode ---
|
||||
# The gate's contract is agreement between --check and a real sync, not "every
|
||||
# property is repaired". Neither touches a generated manifest's permission bits, and
|
||||
# neither can: in check mode the expected side is a `cp -a` of the real plugin root,
|
||||
# so apm pack rewrites a file whose mode is already the actual side's. A revision that
|
||||
# listed these paths in the mode manifest was measuring that inheritance, not the
|
||||
# mirror — `chmod 600` left --check at exit 0 for as long as it was listed. This case
|
||||
# pins the agreement instead, so a future "fix" that makes --check report a mode it
|
||||
# cannot repair fails here.
|
||||
echo ""
|
||||
echo "--- --check detects a mode change on .github/plugin/plugin.json ---"
|
||||
echo "--- --check and a real sync agree that a manifest's mode is not theirs to change ---"
|
||||
FIXTURE25="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25"
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
if ! bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
fail "check reported drift right after the initial sync — cannot test the manifest-mode case"
|
||||
else
|
||||
chmod 600 "$FIXTURE25/.github/plugin/plugin.json"
|
||||
CHECK25="$(bash "$SCRIPT" --check "$FIXTURE25" 2>&1 || true)"
|
||||
case "$CHECK25" in
|
||||
*"mirrored paths/types/modes differ"*)
|
||||
pass "a mode change on .github/plugin/plugin.json is reported as drift" ;;
|
||||
*)
|
||||
fail "no mode drift reported after chmod 600 on .github/plugin/plugin.json" ;;
|
||||
esac
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
MODE25="$(stat -c '%a' "$FIXTURE25/.github/plugin/plugin.json" 2>/dev/null \
|
||||
|| stat -f '%Lp' "$FIXTURE25/.github/plugin/plugin.json" 2>/dev/null)"
|
||||
if [[ "$MODE25" == "644" ]] && bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
pass "re-sync restores mode 644 and clears the drift"
|
||||
if bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
MODE25="$(mode_of "$FIXTURE25/.github/plugin/plugin.json")"
|
||||
if [[ "$MODE25" == "600" ]]; then
|
||||
pass "--check reports no mode drift on a manifest, and a real sync indeed leaves the mode alone"
|
||||
else
|
||||
fail "--check reported no mode drift but a real sync changed the mode to $MODE25 — check and sync disagree"
|
||||
fi
|
||||
else
|
||||
fail "re-sync left .github/plugin/plugin.json at mode $MODE25 / did not clear the drift"
|
||||
fail "--check reported drift after chmod 600 on .github/plugin/plugin.json, but a real sync cannot repair it — an unfixable pre-push failure"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 25b. A manifest replaced by a SYMLINK is real drift and is reported ---
|
||||
# This is the one property of the generated manifests worth asserting, and the reason
|
||||
# it cannot live in check_path_modes: that compares against a `cp -a` of the same
|
||||
# plugin root, which reproduces the symlink on the expected side and calls the two
|
||||
# equal (verified — it sat at exit 0). The hazard is concrete: apm pack opens the
|
||||
# manifest for writing and reinject_mcp_servers redirects into it, and both follow the
|
||||
# link, so a real sync rewrites the link's TARGET instead of the manifest.
|
||||
echo ""
|
||||
echo "--- a plugin.json replaced by a symlink is reported as drift ---"
|
||||
FIXTURE25B="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25B"
|
||||
bash "$SCRIPT" "$FIXTURE25B" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE25B/.claude-plugin/plugin.json" ]]; then
|
||||
fail "sync produced no .claude-plugin/plugin.json — cannot test the symlinked-manifest case"
|
||||
else
|
||||
cp "$FIXTURE25B/.claude-plugin/plugin.json" "$FIXTURE25B/decoy.json"
|
||||
rm -f "$FIXTURE25B/.claude-plugin/plugin.json"
|
||||
ln -s ../decoy.json "$FIXTURE25B/.claude-plugin/plugin.json"
|
||||
CHECK25B="$(bash "$SCRIPT" --check "$FIXTURE25B" 2>&1 || true)"
|
||||
case "$CHECK25B" in
|
||||
*"DRIFT $FIXTURE25B/.claude-plugin/plugin.json: is a symlink"*)
|
||||
pass "a symlinked .claude-plugin/plugin.json is reported as drift" ;;
|
||||
*)
|
||||
fail "no drift reported for a symlinked .claude-plugin/plugin.json — a real sync would write through it. Output: $CHECK25B" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- 26. --check compares full permission bits, not just the exec bit ---
|
||||
# The manifest used to record a bare exec/file kind, so `chmod 444` on a mirrored
|
||||
# SKILL.md left --check at exit 0 while a real sync restored 644 — the same
|
||||
@@ -838,6 +883,47 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 26b. The mode comparison must not depend on the runtime umask ---
|
||||
# Case 26's widening from the exec bit to full permission bits is correct for the
|
||||
# files this script COPIES — both sides of the comparison trace to the same checkout.
|
||||
# It is wrong for the files it WRITES: sync_hooks_json creates hooks/hooks.json with
|
||||
# `printf '%s\n' >`, at the RUNTIME umask, while the real side carries the umask of
|
||||
# the checkout that produced the committed file. Those are independent, so on a
|
||||
# umask-002 machine `--check --all` over this repo's own umask-022 checkout reported
|
||||
# `< file 664 hooks/hooks.json` / `> file 644` for every plugin with hooks — a pre-push
|
||||
# failure with nothing wrong, and unfixable by committing, since git records no
|
||||
# non-exec mode and the next --check from a umask-022 machine fails the other way.
|
||||
#
|
||||
# Two directions, because a one-sided assertion passes on the wrong fix: (a) the same
|
||||
# tree checked under several runtime umasks, and (b) a tree whose GENERATED files carry
|
||||
# a foreign umask — which is exactly what a umask-002 clone of a umask-022 commit looks
|
||||
# like, git having recorded nothing to distinguish them.
|
||||
echo ""
|
||||
echo "--- --check is umask-independent over the files this script generates ---"
|
||||
FIXTURE26B="$(umask 022; make_fixture)"; track "$FIXTURE26B"
|
||||
(umask 022; bash "$SCRIPT" "$FIXTURE26B" > /dev/null 2>&1)
|
||||
if [[ ! -f "$FIXTURE26B/hooks/hooks.json" ]]; then
|
||||
fail "sync did not create hooks/hooks.json — cannot test the umask-independence case"
|
||||
else
|
||||
for UMASK26B in 022 002 077; do
|
||||
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
|
||||
pass "--check at umask $UMASK26B is clean on a tree synced at umask 022"
|
||||
else
|
||||
fail "--check at umask $UMASK26B reported drift on a tree synced at umask 022 — the gate is reporting the runner's umask, not the mirror"
|
||||
fi
|
||||
done
|
||||
# What a umask-002 clone of the same commit looks like on disk.
|
||||
chmod 664 "$FIXTURE26B/hooks/hooks.json"
|
||||
[[ -f "$FIXTURE26B/.claude-plugin/plugin.json" ]] && chmod 664 "$FIXTURE26B/.claude-plugin/plugin.json"
|
||||
for UMASK26B in 022 002; do
|
||||
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
|
||||
pass "--check at umask $UMASK26B is clean when the generated files carry a umask-002 checkout's mode"
|
||||
else
|
||||
fail "--check at umask $UMASK26B reported drift on generated files carrying a umask-002 checkout's mode — no commit can fix that"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# --- 27. A plugin-dir argument whose basename is . or .. is rejected ---
|
||||
# Every scratch path is "$SCRATCH_ROOT/$(basename "$plugin_dir")", so `..` resolves
|
||||
# to the scratch root's PARENT: `apm pack -o` then writes outside the tree the EXIT
|
||||
@@ -963,9 +1049,46 @@ check_all_fails_with "unparseable JSON" \
|
||||
check_all_fails_with "a plugins field that is not an array" \
|
||||
'{"plugins":{"a":1}}' \
|
||||
"expected an array of plugin entries"
|
||||
check_all_fails_with "a JSON array at the marketplace root" \
|
||||
'[]' \
|
||||
"is a JSON array at its top level"
|
||||
check_all_fails_with "an entry with no source field" \
|
||||
'{"plugins":[{"name":"orphan"}]}' \
|
||||
"entries with no \`source\` field"
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
# The guard used to name `.source == null` specifically, so every other malformed
|
||||
# value walked straight through it into the same silence.
|
||||
check_all_fails_with "an entry whose source is a number" \
|
||||
'{"plugins":[{"name":"orphan","source":42}]}' \
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
check_all_fails_with "an entry whose source is an array" \
|
||||
'{"plugins":[{"name":"orphan","source":[]}]}' \
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
|
||||
# --- 28b. --all outside a git worktree refuses instead of guessing $PWD ---
|
||||
# --all's entire work list hangs off REPO_ROOT, so a `|| pwd` fallback lets it derive
|
||||
# that list from a marketplace.json belonging to some other tree. Same reasoning
|
||||
# scripts/sync-marketplace-mirror.sh dropped its own fallback on. Run from a directory
|
||||
# with no marketplace.json the old form happened to hit the "--all requires ..." error,
|
||||
# but only by accident — the dangerous case is a $PWD that HAS one.
|
||||
echo ""
|
||||
echo "--- --all outside a git worktree refuses to guess the repository root ---"
|
||||
NOGIT="$(mktemp -d)"; track "$NOGIT"
|
||||
mkdir -p "$NOGIT/.claude-plugin"
|
||||
printf '%s' '{"plugins":[{"name":"decoy","source":"./plugins/decoy"}]}' > "$NOGIT/.claude-plugin/marketplace.json"
|
||||
if (cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --show-toplevel) >/dev/null 2>&1; then
|
||||
fail "fixture precondition: $NOGIT is inside a git worktree, so this case cannot test the no-worktree path"
|
||||
else
|
||||
RC28B=0
|
||||
OUT28B="$(cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all 2>&1)" || RC28B=$?
|
||||
case "$RC28B:$OUT28B" in
|
||||
0:*)
|
||||
fail "--check --all exited 0 outside a worktree, having derived its plugin list from \$PWD" ;;
|
||||
*"not inside a git worktree"*)
|
||||
pass "--all refuses to guess \$PWD when it cannot locate the repository root" ;;
|
||||
*)
|
||||
fail "--check --all exited $RC28B outside a worktree but not for the stated reason: $OUT28B" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
|
||||
Reference in New Issue
Block a user