fix(marketplace): chmod +x .sh files after fetch+reset, not just bootstrap (#352)

Devin Review #350 caught a coverage gap: the chmod +x pass only ran in
_bootstrap_clone (initial install), not in _git_fetch_and_reset (every
subsequent `agnes refresh-marketplace` and `--check` follow-up). On
core.filemode=false setups, a `git reset --hard FETCH_HEAD` overwrites
the working tree without restoring the +x bit, so a hook plugin version
bump would silently re-strip the bit and Permission-denied breakage
would return on the next SessionStart.

Extracted _chmod_clone_sh_files() helper; both _bootstrap_clone and
_git_fetch_and_reset now call it. Best-effort, no-op on Windows NTFS.
This commit is contained in:
Vojtech 2026-05-19 18:10:38 +04:00 committed by GitHub
parent fc6de77e06
commit 318802854c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 28 deletions

View file

@ -10,6 +10,27 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [Unreleased]
### Fixed
- `agnes refresh-marketplace` (non-bootstrap path) now re-applies
`chmod +x` to every `.sh` under `~/.agnes/marketplace` after each
`git reset --hard FETCH_HEAD`, not just on the initial bootstrap
clone. `git reset --hard` rewrites the working tree from the tree
object — if the upstream tree stores a hook script as non-
executable (or on `core.filemode=false` setups), every refresh
silently re-strips the +x bit and the previously-fixed hooks fire
with "Permission denied" again on the next `SessionStart`.
Extracted `_chmod_clone_sh_files()` helper, called from both
`_bootstrap_clone` and `_git_fetch_and_reset`. Best-effort, no-op
on Windows NTFS. Closes the coverage gap Devin Review flagged on
PR #350.
- Stripped six stale unresolved merge-conflict markers
(`<<<<<<<` / `=======` / `>>>>>>>`) from the `[0.55.1]` section of
`CHANGELOG.md` that landed on `main` via PR #350's release-cut
commit. Markers were rendering as raw conflict text on GitHub and
in any tooling that parses the changelog; the HEAD-side content
inside each pair is what was kept (the incoming side held
superseded intermediate-commit duplicates).
## [0.55.2] — 2026-05-19 ## [0.55.2] — 2026-05-19
### Fixed ### Fixed
@ -205,7 +226,6 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
save) mirroring the Memory Domain pattern. save) mirroring the Memory Domain pattern.
### Changed ### Changed
<<<<<<< HEAD
- **Bulk-assign tables → package** modal — package dropdown options - **Bulk-assign tables → package** modal — package dropdown options
now carry a `(N of M tables already in)` suffix so admins see the now carry a `(N of M tables already in)` suffix so admins see the
existing distribution before picking a target. Counts surface existing distribution before picking a target. Counts surface
@ -515,8 +535,6 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
- Single PR cutover (no two-phase rollout). Legacy - Single PR cutover (no two-phase rollout). Legacy
`marketplace_plugins.is_system` + `user_plugin_optouts` retained `marketplace_plugins.is_system` + `user_plugin_optouts` retained
per spec D1 — Marketplace was deliberately not touched. per spec D1 — Marketplace was deliberately not touched.
=======
<<<<<<< HEAD
- /home onboarding Step 2 retitled "turn on permission-skip for setup" - /home onboarding Step 2 retitled "turn on permission-skip for setup"
and now leads with `claude --dangerously-skip-permissions` as the and now leads with `claude --dangerously-skip-permissions` as the
recommended session flag, because the Step 4 paste runs ~20 shell recommended session flag, because the Step 4 paste runs ~20 shell
@ -524,7 +542,6 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
The flag is session-scoped, drops on next plain `claude`. Auto-accept The flag is session-scoped, drops on next plain `claude`. Auto-accept
via Shift + Tab kept as the strict-review fallback for users who want via Shift + Tab kept as the strict-review fallback for users who want
to approve each command; persistent YOLO setup link unchanged. to approve each command; persistent YOLO setup link unchanged.
>>>>>>> 4c4e9e42 (fix(web): swap /home Steps 2↔3, claude --yolo as copy-button command)
## [0.54.29] — 2026-05-19 ## [0.54.29] — 2026-05-19
@ -579,18 +596,6 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
UI shows the corrected project). The orchestrator now compares the UI shows the corrected project). The orchestrator now compares the
two at every rebuild and, if they differ, calls two at every rebuild and, if they differ, calls
`rebuild_from_registry()` to regenerate the extract. `rebuild_from_registry()` to regenerate the extract.
=======
- /home onboarding reordered: folder creation is now Step 2 (was
Step 3) and starting Claude with `claude --dangerously-skip-permissions`
is the new Step 3 (was the auto-mode step), rendered with the same
`.install-cmd` + copy-button affordance the other steps use. Step 4
paste runs ~20 shell commands that auto-accept-edits would not cover
(Bash still prompts), so the YOLO flag is the default recommendation;
session-scoped, drops on next plain `claude`. Shift + Tab → auto-
accept-edits kept as the strict-review fallback; persistent YOLO
allowlist link to /setup-advanced#yolo unchanged. Setup script's
"Verify cwd" warning copy refreshed to reference "/home Step 2".
>>>>>>> c195e0fa (fix(web): swap /home Steps 2↔3, claude --yolo as copy-button command)
- Setup script no longer auto-creates the workspace folder. Step 2 of - Setup script no longer auto-creates the workspace folder. Step 2 of
the pasted prompt now runs `pwd`, compares it to `$HOME/<workspace_dir>` the pasted prompt now runs `pwd`, compares it to `$HOME/<workspace_dir>`
(the folder the /home page's visible Step 3 told the user to create (the folder the /home page's visible Step 3 told the user to create

View file

@ -242,18 +242,7 @@ def _bootstrap_clone(token: str) -> bool:
except OSError: except OSError:
pass pass
# Add execute bit to every `.sh` under the clone — git's checkout doesn't _chmod_clone_sh_files()
# always preserve the file-mode bit (filemode=false repos, archive
# extractions), and Claude Code's later `plugin install` copies the
# files into the workspace `.claude/hooks/` AS-IS, so hooks that lost
# the +x bit here would fire with Permission denied. Fixing at the
# source (marketplace clone) means every downstream plugin install
# gets executable hooks for free. Best-effort: no-op on Windows NTFS.
for sh in CLONE_DIR.rglob("*.sh"):
try:
sh.chmod(sh.stat().st_mode | 0o111)
except OSError:
pass
if not _register_clone_with_claude(CLONE_DIR): if not _register_clone_with_claude(CLONE_DIR):
return False return False
@ -444,12 +433,41 @@ def _local_head_sha() -> Optional[str]:
return sha or None return sha or None
def _chmod_clone_sh_files() -> None:
"""Add execute bit to every `.sh` under CLONE_DIR.
Git's checkout doesn't always preserve the file-mode bit (filemode=false
repos, archive extractions, FUSE/NFS mounts with filemode detection
disabled), and Claude Code's later `plugin install` copies the files
into the workspace `.claude/hooks/` AS-IS, so hooks that lost the +x
bit here would fire with Permission denied. Fixing at the source
(marketplace clone) means every downstream plugin install gets
executable hooks for free.
Called from both `_bootstrap_clone` (after the initial `git clone`)
and `_git_fetch_and_reset` (after every `git reset --hard FETCH_HEAD`
on subsequent refreshes) so a version bump that touches a `.sh`
can't silently strip the bit. Best-effort: no-op on Windows NTFS,
swallows per-file errors.
"""
for sh in CLONE_DIR.rglob("*.sh"):
try:
sh.chmod(sh.stat().st_mode | 0o111)
except OSError:
pass
def _git_fetch_and_reset(token: str) -> bool: def _git_fetch_and_reset(token: str) -> bool:
"""Fetch from origin then hard-reset to FETCH_HEAD. """Fetch from origin then hard-reset to FETCH_HEAD.
Not `pull --ff-only`: the marketplace bare repo on the server rebuilds Not `pull --ff-only`: the marketplace bare repo on the server rebuilds
as a fresh orphan commit on every content change, so two snapshots as a fresh orphan commit on every content change, so two snapshots
have unrelated histories and fast-forward is impossible. have unrelated histories and fast-forward is impossible.
After a successful reset, re-applies the `.sh` execute bit across the
clone `git reset --hard` overwrites working-tree files according to
the repo's `core.filemode` setting, and on systems where that is
`false` the bit gets stripped silently.
""" """
if not _git_fetch_only(token): if not _git_fetch_only(token):
return False return False
@ -465,6 +483,8 @@ def _git_fetch_and_reset(token: str) -> bool:
typer.echo(reset.stderr, err=True) typer.echo(reset.stderr, err=True)
return False return False
_chmod_clone_sh_files()
if reset.stdout: if reset.stdout:
typer.echo(reset.stdout.rstrip()) typer.echo(reset.stdout.rstrip())
return True return True