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

@@ -17,30 +17,51 @@
# The caller then blames whatever its empty-set branch blames -- for
# check-manifests.sh, every plugin directory on disk being unlisted.
#
# It also rejects an entry with no `source` at all. Such an entry is not a local
# plugin (the walk below requires a string `source`) and not a remote one either,
# so it silently drops out of every marketplace-derived work list -- this walk's
# and, through it, sync-plugin-content.sh --all's.
# It also rejects an entry whose `source` is neither a local path string nor a
# remote source object. Only those two shapes are classifiable: the walk below
# takes the string ones, and the object ones are remote. Anything else -- absent
# (`null`), a number, an array, a boolean -- is neither, so it silently drops out
# of every marketplace-derived work list: this walk's and, through it,
# sync-plugin-content.sh --all's.
#
# The check is deliberately typed as "not string AND not object" rather than
# enumerating `.source == null`. Rejecting null specifically left every other
# malformed value (`"source": 42`, `"source": []`) passing the assert, skipped by
# the walk below, AND rescued by check-manifests.sh's disk -> marketplace name
# axis -- i.e. exactly the defect the null case was fixed for, reached with a
# different value.
#
# Exits 1 with a specific message on any violation, so call it from the caller's
# own shell -- never inside `< <(...)`, which is the exact swallowing this guards.
assert_marketplace_manifest_usable() {
local marketplace="$1" plugins_type sourceless
local marketplace="$1" root_type plugins_type unclassifiable
if ! jq empty "$marketplace" >/dev/null 2>&1; then
echo "Error: $marketplace is not valid JSON -- every marketplace-derived check reads as \"no plugins declared\" until it parses. Fix it, or recompile it with \`apm pack\`." >&2
exit 1
fi
# `jq empty` passes on any valid JSON document, including `[]`, `"x"` and `123`.
# The `.plugins` lookup on the next line then aborts with a raw
# `jq: error: Cannot index array with string "plugins"` and rc=5, attributed to
# nothing -- so assert the root shape here, where it can be named.
root_type="$(jq -r 'type' "$marketplace")"
if [[ "$root_type" != "object" ]]; then
echo "Error: $marketplace is a JSON $root_type at its top level; expected an object with a \`plugins\` array. Recompile it with \`apm pack\`." >&2
exit 1
fi
plugins_type="$(jq -r '.plugins | type' "$marketplace")"
if [[ "$plugins_type" != "array" && "$plugins_type" != "null" ]]; then
echo "Error: $marketplace has a \`plugins\` field of type $plugins_type; expected an array of plugin entries." >&2
exit 1
fi
sourceless="$(jq -r '[.plugins[]? | select(.source == null) | .name // "<unnamed>"] | join(", ")' "$marketplace")"
if [[ -n "$sourceless" ]]; then
echo "Error: $marketplace has entries with no \`source\` field: $sourceless. An entry without a \`source\` is neither local nor remote, so it is skipped by every marketplace-derived check while still claiming its name. Give it a \`source\` in root apm.yml's marketplace.packages[] and recompile." >&2
unclassifiable="$(jq -r '[.plugins[]?
| select((.source | type) as $t | $t != "string" and $t != "object")
| "\(.name // "<unnamed>") (source: \(.source | type))"] | join(", ")' "$marketplace")"
if [[ -n "$unclassifiable" ]]; then
echo "Error: $marketplace has entries whose \`source\` is neither a local path string nor a remote source object: $unclassifiable. Such an entry is neither local nor remote, so it is skipped by every marketplace-derived check while still claiming its name. Fix it in root apm.yml's marketplace.packages[] and recompile." >&2
exit 1
fi
}
@@ -64,3 +85,26 @@ list_marketplace_local_plugins() {
printf '%s\t%s\n' "$name" "$repo_root/$source"
done
}
# list_marketplace_remote_plugin_names <marketplace_json_path>
#
# Prints the `name` of every REMOTE (object `source:`) marketplace entry, one per
# line -- the exact complement of list_marketplace_local_plugins.
#
# check-manifests.sh's disk -> marketplace pass uses it as its name axis: a plugin
# vendored on disk but declared with the remote-object shape has no local entry to
# path-match against, so without a name match it would be reported as unlisted when
# its entry is in fact right there.
#
# The select is `(.source | type) == "object"`, an allowlist of the one shape that
# axis is actually for -- NOT the denylist `(.source | type) != "string"` it used to
# be. That denylist was true for `null` (and for numbers, arrays, booleans), so a
# malformed entry marked its same-named directory "listed" while the local walk above
# skipped it for lacking a string source: one bad entry disabled BOTH directions of
# the check at once. assert_marketplace_manifest_usable rejects those shapes too, but
# this function must be correct on its own -- it is called from a different script,
# and a precondition that stops running is not a property of this select.
list_marketplace_remote_plugin_names() {
local marketplace="$1"
jq -r '.plugins[]? | select((.source | type) == "object") | .name // empty' "$marketplace"
}