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:
@@ -31,12 +31,27 @@ set -euo pipefail
|
||||
# plugins/*/.
|
||||
#
|
||||
# Every pass above reads its plugin set out of marketplace.json, so anything that makes
|
||||
# that file yield nothing -- absent, unparseable, or an entry with no `source` -- used to
|
||||
# read as "clean" rather than "unchecked". The guards below turn each of those into an
|
||||
# that file yield nothing -- absent, unparseable, a non-object root, or an entry whose
|
||||
# `source` is neither a path string nor a remote object -- used to read as "clean"
|
||||
# rather than "unchecked". The same is true one level down, of a per-plugin
|
||||
# .claude-plugin/plugin.json that does not parse: it aborted the walk mid-loop and left
|
||||
# every later plugin silently unchecked. The guards below turn each of those into an
|
||||
# explicit, attributable failure instead, because a vacuous pass is the one result a gate
|
||||
# must never produce.
|
||||
|
||||
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
# Hard error, not a `|| pwd` fallback, for the reason spelled out in
|
||||
# scripts/sync-marketplace-mirror.sh: every path below hangs off REPO_ROOT, and the
|
||||
# exit-0 path is "nothing on disk and no manifest", so a REPO_ROOT pointing somewhere
|
||||
# that is not this repo reports "clean" over a tree it never looked at. Run this from
|
||||
# an empty directory outside any worktree and the fallback made that the literal
|
||||
# outcome -- rev-parse failed, REPO_ROOT became $PWD, no plugins/ and no
|
||||
# marketplace.json were found, exit 0, silent.
|
||||
if [[ -n "${1:-}" ]]; then
|
||||
REPO_ROOT="$1"
|
||||
elif ! 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 this check report \"clean\" over a tree it never inspected. Run it from within the repository, or pass the repo root as an argument." >&2
|
||||
exit 1
|
||||
fi
|
||||
FAIL=0
|
||||
|
||||
err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
|
||||
@@ -113,6 +128,11 @@ assert_marketplace_manifest_usable "$MARKETPLACE"
|
||||
# a (legal) string value it returned the character count, and the `.skills[$i]` that
|
||||
# followed aborted the whole script mid-loop under `set -e` with no summary line, so
|
||||
# every plugin later in the marketplace went unchecked.
|
||||
#
|
||||
# The bare `$(jq ...)` assignments below are safe only because the caller has already
|
||||
# established that $manifest parses AND that its root is an object (see the
|
||||
# precondition in the marketplace walk). Do not call this without that check: `set -e`
|
||||
# turns any jq failure in here into the same silent mid-loop abort described above.
|
||||
check_pointer_field() {
|
||||
local name="$1" plugin_dir="$2" field="$3" test_flag="$4"
|
||||
local manifest="$plugin_dir/.claude-plugin/plugin.json"
|
||||
@@ -179,6 +199,25 @@ while IFS=$'\t' read -r name plugin_dir; do
|
||||
# job (see header comment above).
|
||||
[[ -d "$plugin_dir/.apm" ]] && continue
|
||||
|
||||
# Precondition for check_pointer_field, which reads the manifest with bare
|
||||
# `field_type="$(jq ... )"` assignments. Under `set -e` a jq failure in one of
|
||||
# those aborts the whole script mid-loop: rc=5, a raw `jq: parse error` and no
|
||||
# `Manifest check failed:` summary, with every later plugin left unchecked --
|
||||
# the same failure class the marketplace's own `jq empty` precondition closes,
|
||||
# for a file that is equally generated output. Both shapes have to be caught
|
||||
# here: `jq empty` passes on a valid non-object document like `[]` or `123`, and
|
||||
# it is the `.skills` lookup on such a root ("Cannot index array with string")
|
||||
# that aborts, not the parse.
|
||||
if ! jq empty "$manifest" >/dev/null 2>&1; then
|
||||
err "plugin '$name': .claude-plugin/plugin.json is not valid JSON — it is compiled output, so recompile it with \`apm pack\`."
|
||||
continue
|
||||
fi
|
||||
manifest_type="$(jq -r 'type' "$manifest")"
|
||||
if [[ "$manifest_type" != "object" ]]; then
|
||||
err "plugin '$name': .claude-plugin/plugin.json is a JSON $manifest_type at its top level; expected an object."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Fallback for a non-apm plugin: validate that any skills/hooks/mcpServers/agents
|
||||
# pointer fields in its hand-authored plugin.json still resolve to real paths.
|
||||
# skills/agents point at directories; hooks/mcpServers may point at a file.
|
||||
@@ -202,16 +241,16 @@ done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE")
|
||||
# ./plugins/alpha would mark an unrelated, entirely unlisted plugins/beta/ as listed.
|
||||
# Local entries already have an exact path to match on, so they need no name fallback.
|
||||
#
|
||||
# `.source == null` has to be excluded explicitly: `(null | type) != "string"` is TRUE,
|
||||
# so before this guard an entry with no `source` at all landed here and marked its
|
||||
# same-named directory listed -- while the local walk above skipped it for lacking a
|
||||
# string source. One malformed entry thus disabled BOTH directions of the check at once.
|
||||
# assert_marketplace_manifest_usable now rejects such an entry outright; the guard stays
|
||||
# because this select must not depend on that check running first.
|
||||
# The select is an allowlist of the object shape, not a denylist of the string one --
|
||||
# see list_marketplace_remote_plugin_names in scripts/lib/marketplace-plugins.sh, which
|
||||
# owns it, and tests/test-check-manifests.sh, which exercises it directly against
|
||||
# malformed entries rather than through this caller (where
|
||||
# assert_marketplace_manifest_usable rejects them first, and so would mask a regression
|
||||
# in the select itself).
|
||||
MARKETPLACE_NAMES=()
|
||||
while IFS= read -r entry_name; do
|
||||
[[ -n "$entry_name" ]] && MARKETPLACE_NAMES+=("$entry_name")
|
||||
done < <(jq -r '.plugins[]? | select(.source != null and (.source | type) != "string") | .name // empty' "$MARKETPLACE")
|
||||
done < <(list_marketplace_remote_plugin_names "$MARKETPLACE")
|
||||
|
||||
for candidate in ${PLUGIN_DIRS[@]+"${PLUGIN_DIRS[@]}"}; do
|
||||
candidate_abs="$(cd "$candidate" && pwd -P)"
|
||||
|
||||
Reference in New Issue
Block a user