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:
parent
fc6de77e06
commit
318802854c
2 changed files with 53 additions and 28 deletions
37
CHANGELOG.md
37
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue