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]
### 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
### 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.
### Changed
<<<<<<< HEAD
- **Bulk-assign tables → package** modal — package dropdown options
now carry a `(N of M tables already in)` suffix so admins see the
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
`marketplace_plugins.is_system` + `user_plugin_optouts` retained
per spec D1 — Marketplace was deliberately not touched.
=======
<<<<<<< HEAD
- /home onboarding Step 2 retitled "turn on permission-skip for setup"
and now leads with `claude --dangerously-skip-permissions` as the
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
via Shift + Tab kept as the strict-review fallback for users who want
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
@ -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
two at every rebuild and, if they differ, calls
`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
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

View file

@ -242,18 +242,7 @@ def _bootstrap_clone(token: str) -> bool:
except OSError:
pass
# Add execute bit to every `.sh` under the clone — git's checkout doesn't
# 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
_chmod_clone_sh_files()
if not _register_clone_with_claude(CLONE_DIR):
return False
@ -444,12 +433,41 @@ def _local_head_sha() -> Optional[str]:
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:
"""Fetch from origin then hard-reset to FETCH_HEAD.
Not `pull --ff-only`: the marketplace bare repo on the server rebuilds
as a fresh orphan commit on every content change, so two snapshots
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):
return False
@ -465,6 +483,8 @@ def _git_fetch_and_reset(token: str) -> bool:
typer.echo(reset.stderr, err=True)
return False
_chmod_clone_sh_files()
if reset.stdout:
typer.echo(reset.stdout.rstrip())
return True