Skip to content

claude: host-side marketplace tar strips executable bit from plugin hook scripts (regression from #240) #350

@vanakema

Description

@vanakema

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

  1. 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)
  2. moat claude --rebuild (or any fresh build).

  3. 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
    
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions