claude: host-side marketplace tar strips executable bit from plugin hook scripts (regression from #240)
Summary
CollectMarketplaceTar in internal/providers/claude/marketplace.go writes every file's tar header with a hardcoded Mode: 0644. The file's actual mode from fs.DirEntry.Info() is never read. When the build extracts that tar inside the container, every regular file lands as -rw-r--r--, regardless of upstream git mode. Plugin hook scripts that need +x (e.g. bin/aw-hook, scripts/on-prompt-submit.sh) are then unrunnable.
Claude Code surfaces this on every prompt:
UserPromptSubmit hook error
Failed with non-blocking status code: /bin/sh: 1:
/home/moatuser/.claude/plugins/cache/.../bin/aw-hook: Permission denied
Environment
$ moat version
moat 0.5.1
commit: fc0596858df7275a341e3ca195860cd773c4e564
built: 2026-04-28T22:14:27Z
go: go1.25.5
Runtime: Apple containers (macOS, arm64) — but the buggy path runs on all runtimes
Steps to reproduce
-
moat.yaml enabling any marketplace whose plugins ship executable hook scripts. Confirmed on:
warpdotdev/claude-code-warp (10+ files at 100755 in git)
- A private plugin repo of mine with an extensionless binary hook at
bin/<name> (mode 100755 in git)
-
moat claude --rebuild (or any fresh build).
-
Inside the container:
$ ls -la /home/moatuser/.claude/plugins/cache/claude-code-warp/warp/2.0.0/scripts/on-prompt-submit.sh
-rw-r--r-- 1 moatuser moatuser 785 Jan 1 1970 on-prompt-submit.sh
Upstream stores the same file at 100755:
$ gh api repos/warpdotdev/claude-code-warp/git/trees/HEAD?recursive=1 \
--jq '.tree[] | select(.path == "plugins/warp/scripts/on-prompt-submit.sh") | .mode'
100755
-
Submit any prompt → Permission denied hook errors as shown in Summary.
Actual result
All regular files in ~/.claude/plugins/marketplaces/* and ~/.claude/plugins/cache/* are 0644, including files that are 100755 upstream. Files have mtime of Jan 1 1970 (BuildKit reproducible-build normalization; not the cause, just a marker that confirms these files came from the build-context tar).
Expected result
Tar headers preserve the source file mode. Files that are 100755 upstream arrive at 0755 in the container; hooks are executable; no Permission denied errors.
Root cause
internal/providers/claude/marketplace.go:94 (added in #240):
if hdrErr := tw.WriteHeader(&tar.Header{
Name: filepath.ToSlash(rel),
Mode: 0644, // hardcoded; should derive from info.Mode().Perm()
Size: int64(len(fileData)),
}); hdrErr != nil {
return fmt.Errorf("writing tar header for %s: %w", rel, hdrErr)
}
info is already in scope from d.Info() a few lines above, so int64(info.Mode().Perm()) is available without restructuring. The directory branch at line 70 uses hardcoded 0755, which is incidentally correct for normal directories but follows the same anti-pattern.
The path that produces this tar (cloneMarketplacesOnHost in internal/run/manager.go) runs unconditionally — there is no runtime branch on Docker vs Apple containers. A marketplace that successfully clones on the host hits this path; only marketplaces that fail to clone on the host fall back to the in-container clone (which would preserve modes via real git checkout).
Why this is a regression
Before #240 (feat(claude): host-side marketplace cloning for private plugin repos, merged 2026-03-23), marketplaces were cloned inside the container via claude plugin marketplace add at build time. That path uses real git clone, which preserves the stored tree mode. Files arrived at 0755 and hooks worked.
After #240, public marketplaces (which always succeed on the host clone) hit the new pre-clone path and the 0644 hardcode strips +x.
This likely hasn't surfaced widely because most plugins are pure command/skill content with no executable hook scripts.
Workaround
Add to moat.yaml and moat claude --rebuild. Note: the find must be on a single line — multi-line post_build_root is independently mangled by formatHookCommand (filed separately).
post_build_root: |
find /home/moatuser/.claude/plugins -type f \( -path '*/bin/*' -o -path '*/scripts/*' -o -path '*/hooks/*' -o -name '*.sh' \) -not -path '*/.git/*' -not -path '*/node_modules/*' -exec chmod +x {} + 2>/dev/null || true
Runs after claude plugin install in the Dockerfile, baking correct modes into the image.
claude: host-side marketplace tar strips executable bit from plugin hook scripts (regression from #240)
Summary
CollectMarketplaceTarininternal/providers/claude/marketplace.gowrites every file's tar header with a hardcodedMode: 0644. The file's actual mode fromfs.DirEntry.Info()is never read. When the build extracts that tar inside the container, every regular file lands as-rw-r--r--, regardless of upstream git mode. Plugin hook scripts that need+x(e.g.bin/aw-hook,scripts/on-prompt-submit.sh) are then unrunnable.Claude Code surfaces this on every prompt:
Environment
Steps to reproduce
moat.yamlenabling any marketplace whose plugins ship executable hook scripts. Confirmed on:warpdotdev/claude-code-warp(10+ files at100755in git)bin/<name>(mode100755in git)moat claude --rebuild(or any fresh build).Inside the container:
Upstream stores the same file at
100755:Submit any prompt →
Permission deniedhook errors as shown in Summary.Actual result
All regular files in
~/.claude/plugins/marketplaces/*and~/.claude/plugins/cache/*are0644, including files that are100755upstream. Files havemtimeofJan 1 1970(BuildKit reproducible-build normalization; not the cause, just a marker that confirms these files came from the build-context tar).Expected result
Tar headers preserve the source file mode. Files that are
100755upstream arrive at0755in the container; hooks are executable; noPermission deniederrors.Root cause
internal/providers/claude/marketplace.go:94(added in #240):infois already in scope fromd.Info()a few lines above, soint64(info.Mode().Perm())is available without restructuring. The directory branch at line 70 uses hardcoded0755, which is incidentally correct for normal directories but follows the same anti-pattern.The path that produces this tar (
cloneMarketplacesOnHostininternal/run/manager.go) runs unconditionally — there is no runtime branch on Docker vs Apple containers. A marketplace that successfully clones on the host hits this path; only marketplaces that fail to clone on the host fall back to the in-container clone (which would preserve modes via realgit checkout).Why this is a regression
Before #240 (
feat(claude): host-side marketplace cloning for private plugin repos, merged 2026-03-23), marketplaces were cloned inside the container viaclaude plugin marketplace addat build time. That path uses realgit clone, which preserves the stored tree mode. Files arrived at0755and hooks worked.After #240, public marketplaces (which always succeed on the host clone) hit the new pre-clone path and the 0644 hardcode strips
+x.This likely hasn't surfaced widely because most plugins are pure command/skill content with no executable hook scripts.
Workaround
Add to
moat.yamlandmoat claude --rebuild. Note: thefindmust be on a single line — multi-linepost_build_rootis independently mangled byformatHookCommand(filed separately).Runs after
claude plugin installin the Dockerfile, baking correct modes into the image.