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:
@@ -165,7 +165,17 @@ if [[ "$ALL" -eq 1 ]]; then
|
||||
# the same one scripts/check-manifests.sh uses, instead of hand-maintaining a
|
||||
# duplicate walk at every call site (see .pre-commit-config.yaml's
|
||||
# check-plugin-content-sync).
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
#
|
||||
# Hard error rather than a `|| pwd` fallback, on scripts/sync-marketplace-mirror.sh's
|
||||
# reasoning: --all's entire work list hangs off REPO_ROOT, so a REPO_ROOT pointing at
|
||||
# something that is not this repo checks a plugin set that is not this repo's. Run
|
||||
# from outside a worktree the fallback happens to hit the `--all requires ...` error
|
||||
# below instead -- but only by accident, because $PWD had no marketplace.json in it;
|
||||
# $PWD holding an unrelated one is the case that would silently "pass".
|
||||
if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || [[ -z "$REPO_ROOT" ]]; then
|
||||
echo "Error: not inside a git worktree -- cannot locate the repository root, and guessing \$PWD would let --all derive its plugin list from a marketplace.json that is not this repo's. Run this from within the repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json"
|
||||
if [[ ! -f "$MARKETPLACE" ]]; then
|
||||
echo "Error: --all requires $MARKETPLACE" >&2
|
||||
@@ -341,34 +351,56 @@ check_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Relative paths whose mode the manifest below deliberately does NOT record. See
|
||||
# path_manifest's comment for the rule; this is the list of paths it applies to --
|
||||
# every file this pipeline WRITES rather than `cp -a`s.
|
||||
NO_MODE_PATHS=("$HOOKS_REL" "$LEGACY_HOOKS_REL")
|
||||
|
||||
# Prints "<kind> <octal-mode> <relative-path>" for every entry under the given
|
||||
# relative paths. `find` walks; $STAT_MODE_ARGS (probed once at startup) reads the
|
||||
# mode, because stat's flags for mode formatting are incompatible between GNU and
|
||||
# BSD/macOS.
|
||||
#
|
||||
# Full permission bits on FILES, not just the exec bit: an earlier revision emitted
|
||||
# a bare `exec`/`file` kind, so `chmod 444` on a mirrored SKILL.md left --check at
|
||||
# exit 0 while a real sync restored 644 -- check and sync disagreeing again, in the
|
||||
# same shape the exec-bit case already proved. Git tracks only the exec bit, so this
|
||||
# cannot arrive via a clone, but the gate's contract is that it agrees with a real
|
||||
# sync about everything a real sync writes.
|
||||
# THE RULE: a mode is recorded for a path this pipeline COPIES, and not for one it
|
||||
# WRITES. The two sides of the comparison are a git checkout (actual) and a fresh
|
||||
# apm-pack-plus-mirror (expected), so a copied path's mode traces to the same
|
||||
# checkout on both sides and comparing it is meaningful; a written path's mode is
|
||||
# `0666 & ~umask` of whichever process wrote it -- the runtime umask on the expected
|
||||
# side, the umask of the checkout that produced the committed file on the actual
|
||||
# side. Those two are independent, git records neither, and no sync can make them
|
||||
# converge, so comparing them reports the runner's umask instead of a property of
|
||||
# the mirror.
|
||||
#
|
||||
# DIRECTORIES record no mode, deliberately. Nothing in this script ever sets one:
|
||||
# the expected side's directories come from `mkdir -p` and `cp -a` under the
|
||||
# running process's umask, the real side's from git checkout under whatever umask
|
||||
# cloned the repo, and git tracks no directory mode at any point in between. So the
|
||||
# comparison would report the runner's umask rather than any property of the
|
||||
# mirror -- a repo cloned at umask 002 and checked at 022 would fail this gate on
|
||||
# every directory with nothing wrong. Verified concretely: extracting this repo
|
||||
# with `git archive | tar -x` (which restores 0775/0664 when run as root) makes a
|
||||
# --check against the extracted tree report drift on every mirrored directory,
|
||||
# while the same check against the real 0755 tree is silent. Mirrored FILES do not
|
||||
# have this problem: both sides trace to the same checkout, since the expected side
|
||||
# is `cp -a`'d from a bundle apm built out of the same .apm/ files.
|
||||
# Full permission bits on COPIED FILES, not just the exec bit: an earlier revision
|
||||
# emitted a bare `exec`/`file` kind, so `chmod 444` on a mirrored SKILL.md left
|
||||
# --check at exit 0 while a real sync restored 644 -- check and sync disagreeing
|
||||
# again, in the same shape the exec-bit case already proved. Git tracks only the exec
|
||||
# bit, so this cannot arrive via a clone, but the gate's contract is that it agrees
|
||||
# with a real sync about everything a real sync writes.
|
||||
#
|
||||
# DIRECTORIES record no mode: nothing here sets one, they come from `mkdir -p` and
|
||||
# `cp -a`, and git tracks no directory mode. Verified concretely -- extracting this
|
||||
# repo with `git archive | tar -x` (which restores 0775/0664 when run as root) makes
|
||||
# a --check against the extracted tree report drift on every mirrored directory,
|
||||
# while the same check against the real 0755 tree is silent.
|
||||
#
|
||||
# $NO_MODE_PATHS record no mode for the identical reason, and this is where the
|
||||
# "copied files are immune because both sides trace to the same checkout" premise
|
||||
# stops holding. hooks/hooks.json is not copied: sync_hooks_json writes it with
|
||||
# `printf '%s\n' >`, at the RUNTIME umask. Widening the file comparison from the exec
|
||||
# bit to full permission bits therefore made the gate umask-dependent -- on a
|
||||
# umask-002 machine, `--check --all` over a umask-022 checkout reported
|
||||
# `< file 664 hooks/hooks.json` / `> file 644` for every plugin with hooks, and it
|
||||
# was not fixable by committing: a real sync writes 664, `git status` stays empty
|
||||
# because git tracks no non-exec mode, and the next --check from a umask-022 machine
|
||||
# fails in the opposite direction.
|
||||
#
|
||||
# The two generated plugin.json manifests are not in this manifest AT ALL -- see
|
||||
# sync_one's checked_paths for why listing them measured nothing.
|
||||
path_manifest() {
|
||||
local root="$1"
|
||||
shift
|
||||
local rel f kind mode
|
||||
local rel f kind mode no_mode
|
||||
for rel in "$@"; do
|
||||
if [[ ! -e "$root/$rel" ]] && [[ ! -L "$root/$rel" ]]; then
|
||||
continue
|
||||
@@ -386,6 +418,12 @@ path_manifest() {
|
||||
else
|
||||
kind="file"
|
||||
mode="$(stat "${STAT_MODE_ARGS[@]}" "$f")"
|
||||
for no_mode in "${NO_MODE_PATHS[@]}"; do
|
||||
if [[ "${f#"$root"/}" == "$no_mode" ]]; then
|
||||
mode="-"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
printf '%s %s %s\n' "$kind" "$mode" "${f#"$root"/}"
|
||||
done || true
|
||||
@@ -462,13 +500,17 @@ reinject_mcp_servers() {
|
||||
# manifest. Git records only the exec bit, so the demotion survived every
|
||||
# commit and review unnoticed -- plugins/bin/.github/plugin/plugin.json really
|
||||
# was 0600 on disk while its five siblings were 0644. Redirecting into $dst
|
||||
# keeps its inode, owner and mode; the chmod then pins the mode of a file this
|
||||
# script owns as generated output, so a fresh sync and a re-sync over a
|
||||
# tampered tree converge on the same answer (and --check, which now carries
|
||||
# this path in its mode manifest, can see when they would not).
|
||||
# keeps its inode, owner and mode, which is the whole fix: whatever mode apm
|
||||
# pack gave the manifest a moment ago is exactly the mode it still has.
|
||||
#
|
||||
# Deliberately NO `chmod 644` after it. A hardcoded mode here does not pin
|
||||
# anything a re-sync could converge on -- apm pack created $dst at the runtime
|
||||
# umask, and the committed file carries the umask of the checkout that produced
|
||||
# it -- it only makes those two disagree. It did: on a umask-002 checkout,
|
||||
# --check reported `< file 644 .github/plugin/plugin.json` / `> file 664` with
|
||||
# nothing wrong. See path_manifest's $NO_MODE_PATHS comment for the rule.
|
||||
cat "$tmp" >"$dst"
|
||||
rm -f "$tmp"
|
||||
chmod 644 "$dst"
|
||||
}
|
||||
|
||||
# --check-only: diffs a freshly-regenerated manifest file (in the throwaway
|
||||
@@ -479,6 +521,18 @@ sync_plugin_manifest() {
|
||||
local plugin_dir="$1" pack_cwd="$2" rel="$3"
|
||||
local src="$pack_cwd/$rel" dst="$plugin_dir/$rel"
|
||||
|
||||
# A manifest that is a symlink is not a cosmetic difference: apm pack opens it
|
||||
# for writing and reinject_mcp_servers redirects into it, and both follow the
|
||||
# link -- so a real sync silently rewrites whatever it points at instead of the
|
||||
# manifest. It has to be asserted against the real plugin root like this, not via
|
||||
# check_path_modes: that compares against a `cp -a` of this same root, which
|
||||
# reproduces the symlink on the expected side and reports the two as equal.
|
||||
if [[ -L "$dst" ]]; then
|
||||
echo "DRIFT $dst: is a symlink -- apm pack and the mcpServers re-injection both write THROUGH it, so a real sync would overwrite its target instead of the manifest. Replace it with a regular file." >&2
|
||||
FAIL=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -f "$src" ]]; then
|
||||
if [[ ! -f "$dst" ]]; then
|
||||
echo "DRIFT $dst: missing (would be created by apm pack from apm.yml/.mcp.json)" >&2
|
||||
@@ -577,14 +631,21 @@ sync_one() {
|
||||
# generated directory (see check_path_modes), and listing it recursively already
|
||||
# covers hooks/hooks.json.
|
||||
#
|
||||
# .github/plugin/plugin.json is in the list even though sync_plugin_manifest
|
||||
# below already diffs its CONTENT: that diff is content-only, so the mode
|
||||
# reinject_mcp_servers writes was outside --check's manifest entirely and the
|
||||
# 0600 demotion above went undetected for as long as it existed. Its sibling
|
||||
# .claude-plugin/plugin.json is listed for the same reason -- nothing here
|
||||
# writes its mode today, which is exactly the state worth pinning.
|
||||
checked_paths=("${MIRROR_DIRS[@]}" "$HOOKS_DIR_REL" "$LEGACY_HOOKS_REL" \
|
||||
".claude-plugin/plugin.json" ".github/plugin/plugin.json")
|
||||
# Neither generated plugin.json is listed here, and adding one back measures
|
||||
# nothing on any of the three axes this manifest compares. Mode: in check mode
|
||||
# the expected side is the seeded pack_cwd COPY of the real plugin root, where
|
||||
# apm pack rewrites a file that is already there and open-for-write preserves
|
||||
# the existing inode's mode -- so the expected mode is inherited from the actual
|
||||
# mode by construction. Verified: `chmod 600 plugins/bin/.claude-plugin/
|
||||
# plugin.json` left --check at exit 0 the entire time that entry was listed.
|
||||
# Type: a symlinked manifest survives the same `cp -a` as a symlink and apm pack
|
||||
# writes straight through it, so both sides record `symlink` -- also verified at
|
||||
# exit 0. Presence: sync_plugin_manifest already reports both directions, with a
|
||||
# message naming apm.yml as the thing to fix. A symlinked manifest IS a real
|
||||
# hazard (apm pack and reinject_mcp_servers both write through it, corrupting
|
||||
# whatever it points at), so it is asserted where it can actually be seen --
|
||||
# against the real plugin root, in sync_plugin_manifest.
|
||||
checked_paths=("${MIRROR_DIRS[@]}" "$HOOKS_DIR_REL" "$LEGACY_HOOKS_REL")
|
||||
for d in "${MIRROR_DIRS[@]}"; do
|
||||
check_dir "$plugin_dir" "$pack_cwd" "$d"
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user