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:
2026-08-14 12:29:55 +00:00
parent aa15fc850c
commit a700b3771c
5 changed files with 490 additions and 80 deletions

View File

@@ -467,7 +467,83 @@ JSON
printf 'name: lint\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11/plugins/lint/apm.yml"
assert_fails_with "$FIXTURE11" \
"an entry with no source: is reported by name instead of silently disabling both checks" \
'no `source` field' 'lint'
'`source` is neither a local path string nor a remote source object' 'lint (source: null)'
# --- 11b. Any other unclassifiable `source` is rejected the same way ---
# The guard used to test `.source == null` specifically, so every OTHER malformed value
# reached exactly the state the null case was fixed for: `"source": 42` passed the
# assert, was skipped by list_marketplace_local_plugins for not being a string, AND was
# rescued by the disk -> marketplace name axis (whose select was the denylist
# `(.source|type) != "string"`, true for a number). Verbatim the same defect, one value
# over. Only two shapes are classifiable -- a local path string and a remote source
# object -- so the guard is typed as "neither of those", not as a list of known-bad
# values.
echo ""
echo "--- a non-string, non-object source: is rejected by type, not by enumerating null ---"
for BAD_SOURCE in '42' '[]' 'true'; do
FIXTURE11B="$(mktemp -d)"
FIXTURES+=("$FIXTURE11B")
mkdir -p "$FIXTURE11B/.claude-plugin" "$FIXTURE11B/plugins/lint"
printf '{ "name": "test-marketplace", "plugins": [ { "name": "lint", "source": %s } ] }\n' \
"$BAD_SOURCE" > "$FIXTURE11B/.claude-plugin/marketplace.json"
printf 'name: lint\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11B/plugins/lint/apm.yml"
assert_fails_with "$FIXTURE11B" \
"a source: of $BAD_SOURCE is rejected instead of silently disabling both checks" \
'`source` is neither a local path string nor a remote source object' 'lint (source:'
done
# --- 11c. The disk -> marketplace name axis, exercised WITHOUT the precondition ---
# This is the one assertion that cannot go through bash "$SCRIPT": every malformed entry
# the select must reject is rejected first by assert_marketplace_manifest_usable, which
# exits before the select ever runs. So reverting the select alone left the whole suite
# green -- the code carried a comment claiming it "must not depend on that check running
# first", and nothing tested that independence. Call the function directly instead.
#
# The invariant: the name axis exists solely for a plugin vendored on disk under a
# REMOTE (object) `source:`, which has no local path to match on. Every other shape --
# a local string (which matches by path and needs no name fallback, see case 9d) and
# every unclassifiable value -- must produce no name at all.
echo ""
echo "--- list_marketplace_remote_plugin_names emits object-source names only ---"
# shellcheck source=scripts/lib/marketplace-plugins.sh
source "$REPO_ROOT/scripts/lib/marketplace-plugins.sh"
FIXTURE11C="$(mktemp -d)"
FIXTURES+=("$FIXTURE11C")
cat > "$FIXTURE11C/marketplace.json" <<'JSON'
{
"name": "test-marketplace",
"plugins": [
{ "name": "remote-obj", "source": { "repo": "someorg/somerepo", "source": "github" } },
{ "name": "local-str", "source": "./plugins/local-str" },
{ "name": "null-src" },
{ "name": "explicit-null", "source": null },
{ "name": "number-src", "source": 42 },
{ "name": "array-src", "source": [] },
{ "name": "bool-src", "source": true }
]
}
JSON
NAMES11C="$(list_marketplace_remote_plugin_names "$FIXTURE11C/marketplace.json")"
if [[ "$NAMES11C" == "remote-obj" ]]; then
pass "only the remote object-source entry yields a name for the disk -> marketplace name axis"
else
fail "the name axis emitted $(printf '%s' "$NAMES11C" | tr '\n' ' ')— expected exactly 'remote-obj'; every other shape would rescue a same-named orphan directory"
fi
# --- 11d. A valid-JSON, non-object marketplace root is named, not left to crash jq ---
# `jq empty` passes on `[]`, `"x"` and `123`; the `.plugins` lookup on the next line then
# died with a raw `jq: error: Cannot index array with string "plugins"` and rc=5,
# attributed to nothing at all.
echo ""
echo "--- a valid-JSON non-object marketplace root is reported as such ---"
FIXTURE11D="$(mktemp -d)"
FIXTURES+=("$FIXTURE11D")
mkdir -p "$FIXTURE11D/.claude-plugin" "$FIXTURE11D/plugins/one"
printf '[]\n' > "$FIXTURE11D/.claude-plugin/marketplace.json"
printf 'name: one\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11D/plugins/one/apm.yml"
assert_fails_with "$FIXTURE11D" \
"a JSON array at the marketplace root is named as a root-shape error" \
'is a JSON array at its top level'
# --- 12. A `skills` string (a legal shape per the host docs) is resolved, not counted ---
# `jq '.skills | if . then length else 0 end'` is null-safe but not type-safe: on the
@@ -623,6 +699,73 @@ mkdir -p "$FIXTURE18B/plugins/scratch/notes" "$FIXTURE18B/docs"
assert_passes "$FIXTURE18B" \
"no marketplace.json and no plugin-marked directories is a genuine no-op, not a failure"
# --- 19. An unparseable per-plugin plugin.json is attributed, and the walk continues ---
# check_pointer_field reads the manifest with bare `$(jq ...)` assignments, so under
# `set -e` a parse failure aborted the whole script mid-loop: rc=5, a raw
# `jq: parse error` on stderr, no `Manifest check failed:` summary, and every plugin
# later in the marketplace silently unchecked. That is the same failure class the
# marketplace's own `jq empty` precondition closes -- and .claude-plugin/plugin.json is
# equally generated output, so it is equally capable of being corrupt.
#
# The second entry is broken in an unrelated way; both messages plus the summary must
# appear, which is what proves the walk survived the first fault.
echo ""
echo "--- an unparseable plugin.json is reported and does not abort the marketplace walk ---"
FIXTURE19="$(mktemp -d)"
FIXTURES+=("$FIXTURE19")
mkdir -p "$FIXTURE19/plugins/corrupt/.claude-plugin" "$FIXTURE19/plugins/second"
write_marketplace "$FIXTURE19" "corrupt=./plugins/corrupt" "second=./plugins/second"
printf '{ "name": "corrupt",\n' > "$FIXTURE19/plugins/corrupt/.claude-plugin/plugin.json"
assert_fails_with "$FIXTURE19" \
"an unparseable plugin.json is named and later plugins are still checked" \
"plugin 'corrupt': .claude-plugin/plugin.json is not valid JSON" \
"plugin 'second': .claude-plugin/plugin.json not found" \
'Manifest check failed: 2 error(s)'
# --- 19b. A valid-JSON but non-object plugin.json is caught too ---
# `jq empty` passes on `[]`; it is the `.skills` lookup on such a root that aborts
# ("Cannot index array with string"), not the parse -- so the parse check alone would
# leave this exact crash reachable.
echo ""
echo "--- a valid-JSON non-object plugin.json is reported, not left to crash jq ---"
FIXTURE19B="$(mktemp -d)"
FIXTURES+=("$FIXTURE19B")
mkdir -p "$FIXTURE19B/plugins/arrayjson/.claude-plugin" "$FIXTURE19B/plugins/second"
write_marketplace "$FIXTURE19B" "arrayjson=./plugins/arrayjson" "second=./plugins/second"
printf '[]\n' > "$FIXTURE19B/plugins/arrayjson/.claude-plugin/plugin.json"
assert_fails_with "$FIXTURE19B" \
"a non-object plugin.json is named by type and later plugins are still checked" \
"plugin 'arrayjson': .claude-plugin/plugin.json is a JSON array at its top level" \
"plugin 'second': .claude-plugin/plugin.json not found" \
'Manifest check failed: 2 error(s)'
# --- 20. Run with no argument outside a worktree: refuse, do not guess $PWD ---
# Every path this script touches hangs off REPO_ROOT, and its exit-0 path is "no
# manifest and nothing on disk" -- so `|| pwd` made a run from an empty directory
# outside any worktree exit 0, silently, having inspected no repository at all. Same
# reasoning as scripts/sync-marketplace-mirror.sh, which dropped its fallback first.
#
# `env -u GIT_DIR -u GIT_WORK_TREE` because run-tests.sh runs as a pre-push hook and git
# hooks export both, which would re-target `git rev-parse --show-toplevel` at the LIVE
# repo from any cwd -- making this case pass for the wrong reason.
echo ""
echo "--- with no argument outside a git worktree, it refuses instead of guessing \$PWD ---"
FIXTURE20="$(mktemp -d)"
FIXTURES+=("$FIXTURE20")
if (cd "$FIXTURE20" && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --show-toplevel) >/dev/null 2>&1; then
fail "fixture precondition: $FIXTURE20 is inside a git worktree, so this case cannot test the no-worktree path"
else
RC20=0
OUT20="$(cd "$FIXTURE20" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" 2>&1)" || RC20=$?
if [[ $RC20 -eq 0 ]]; then
fail "exited 0 from outside a worktree with no argument -- it checked nothing and said so to no one"
elif [[ "$OUT20" != *"not inside a git worktree"* ]]; then
fail "exited $RC20 outside a worktree but not for the stated reason. Output: $OUT20"
else
pass "refuses to guess \$PWD when it cannot locate the repository root"
fi
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]