2026-05-08-issue-38-bridge-exec-bit.md
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | --- |
| 2 | date: 2026-05-08 |
| 3 | status: filed |
| 4 | issue_number: 38 |
| 5 | issue_id: sha256:081b28c4c317eb0cc4008e42d86897ab884b79b12aef4f3b7c912fd447ed3711 |
| 6 | repo: gabriel/musehub |
| 7 | url: https://staging.musehub.ai/gabriel/musehub/issues/38 |
| 8 | title: muse bridge git-export strips POSIX executable bit from all files |
| 9 | labels: [bug, bridge] |
| 10 | patch_status: verified-locally |
| 11 | event_type: discover |
| 12 | tags: [contributions, musehub, bug-report, bridge, git-export] |
| 13 | --- |
| 14 | |
| 15 | # Issue #38 β `muse bridge git-export` strips POSIX executable bit from all files |
| 16 | |
| 17 | Filed: 2026-05-08 |
| 18 | Repo: gabriel/musehub |
| 19 | URL: https://staging.musehub.ai/gabriel/musehub/issues/38 |
| 20 | |
| 21 | Discovered during pre-merge `git diff --summary` review of the architecture-decision PR. Patch validated end-to-end against `aaronrene/knowtation` `staging/main` HEAD `sha256:8514304e16d7β¦`. 41 files correctly received `100755`, 572 stayed `100644`, zero false positives. |
| 22 | |
| 23 | Patch could not be submitted as a merge proposal because `muse clone gabriel/muse` is broken (see issue #39). |
| 24 | |
| 25 | ## Issue body (as filed) |
| 26 | |
| 27 | # `muse bridge git-export` strips POSIX executable bit from all files |
| 28 | |
| 29 | **Affected component:** `muse/cli/commands/bridge.py::GitExporter.fix_file_modes` (and an underlying limitation in `muse/core/snapshot.py`) |
| 30 | **Affected versions:** `0.2.0rc7` (latest installed), and almost certainly all earlier versions that ship `bridge.py` |
| 31 | **Severity:** High β silently breaks CI/CD pipelines, deploy scripts, and any executable artefact mirrored from Muse to a Git remote |
| 32 | **Reporter:** @aaronrene |
| 33 | **Patch verified locally:** Yes β applied to local `0.2.0rc7` install and re-bridged `aaronrene/knowtation` against canonical `staging/main`. **41 files** correctly received `100755` (15 originally identified + 26 additional that had also been silently stripped); **572 files** correctly stayed `100644`. Zero false positives, zero false negatives across the 613-file sample. |
| 34 | |
| 35 | > **Note on contribution path:** This issue documents a complete, working fix. Reporter would have submitted as a merge proposal against `gabriel/muse` rather than as an issue, but `muse clone https://staging.musehub.ai/gabriel/muse` currently fails with 25+ `Content integrity failure` errors followed by `HTTP 404` on a presigned object URL, and `muse clone https://musehub.ai/gabriel/muse` returns `Server returned invalid msgpack: unpack(b) received extra data`. Filing a separate issue for the clone failures. Once cloning works, happy to convert this into a proper merge proposal with co-authored-by attribution. |
| 36 | |
| 37 | --- |
| 38 | |
| 39 | ## Plain-language summary |
| 40 | |
| 41 | When `muse bridge git-export` mirrors a Muse repo to a Git working tree, every file lands at mode `644` (not executable) β even if the same path was previously committed to the Git remote with mode `755` (executable). Shell scripts, Node CLI scripts, and any other file that needs to be runnable lose their executable bit on the next bridge sync. The `.gitignore`, content, and history are perfectly preserved; only the POSIX permission flag is dropped. |
| 42 | |
| 43 | ## Technical summary |
| 44 | |
| 45 | `GitExporter.fix_file_modes` (`muse/cli/commands/bridge.py:2069-2090`) unconditionally calls `chmod(0o644)` for every path in the manifest. Its CLI help string at `:2905` advertises `"chmod files after writing (644 for files, 755 for executables)"`, but the implementation has no concept of "executable" β there is no shebang check, no extension check, no `.museattributes` lookup. The `--fix-modes` flag itself defaults to `False` (`:2904`), so the chmod path is rarely exercised; when it is exercised, it actively destroys exec bits. |
| 46 | |
| 47 | A grep across the entire `muse` package for `S_IXUSR | S_IEXEC | 0o755 | executable` returns zero matches that pertain to file mode tracking β only `sys.executable` (Python interpreter path) and `S_ISREG` (is-regular-file) checks. **Muse's snapshot model itself does not capture POSIX mode bits**: `core/snapshot.py:244-267` only uses `st.st_mode` to filter `S_ISREG`, and the on-disk manifest format documented at `core/snapshot.py:11-14` derives `snapshot_id` from `(path, object_id)` pairs only β no mode field exists. |
| 48 | |
| 49 | Because the snapshot has no mode information, the bridge cannot reconstruct exec bits from manifest data. **A heuristic at export time is the correct fix scope** β it requires no schema change, preserves backward-compatible `snapshot_id` derivation, and deploys without migration. |
| 50 | |
| 51 | ## Reproducer |
| 52 | |
| 53 | ```bash |
| 54 | # 1. Init a fresh Muse repo with a script |
| 55 | mkdir /tmp/muse-exec-repro && cd /tmp/muse-exec-repro |
| 56 | muse init --domain code |
| 57 | mkdir scripts |
| 58 | cat > scripts/hello.sh <<'EOF' |
| 59 | #!/usr/bin/env bash |
| 60 | echo "hello" |
| 61 | EOF |
| 62 | chmod +x scripts/hello.sh |
| 63 | ls -l scripts/hello.sh # confirms -rwxr-xr-x |
| 64 | |
| 65 | # 2. Commit |
| 66 | muse code add scripts/hello.sh |
| 67 | muse commit -m "add hello script" |
| 68 | |
| 69 | # 3. Bridge to a fresh Git dir |
| 70 | mkdir /tmp/muse-exec-repro-git && cd /tmp/muse-exec-repro-git && git init -q |
| 71 | cd /tmp/muse-exec-repro |
| 72 | muse bridge git-export --git-dir /tmp/muse-exec-repro-git --fix-modes |
| 73 | |
| 74 | # 4. Observe loss |
| 75 | cd /tmp/muse-exec-repro-git |
| 76 | ls -l scripts/hello.sh # -rw-r--r-- (NOT executable) |
| 77 | git add . && git commit -q -m "mirror" |
| 78 | git ls-tree HEAD scripts/hello.sh # 100644 (expected: 100755) |
| 79 | ./scripts/hello.sh # bash: ./scripts/hello.sh: Permission denied |
| 80 | ``` |
| 81 | |
| 82 | ## Real-world impact (concrete case from the field) |
| 83 | |
| 84 | A bridge sync of `aaronrene/knowtation` (`staging/main`) to `aaronrene/knowtation` on GitHub produced a 39-file delta where 15 of those files were **mode-only changes** stripping `100755 β 100644`: |
| 85 | |
| 86 | ``` |
| 87 | mode change 100755 => 100644 deploy/paperclip/install.sh |
| 88 | mode change 100755 => 100644 deploy/paperclip/scripts/hello-world-test.sh |
| 89 | mode change 100755 => 100644 deploy/paperclip/scripts/load-skills-and-agents.sh |
| 90 | mode change 100755 => 100644 deploy/paperclip/scripts/push-secrets.sh |
| 91 | mode change 100755 => 100644 deploy/paperclip/scripts/run-controller.sh |
| 92 | mode change 100755 => 100644 deploy/paperclip/scripts/wire-knowtation-mcp.sh |
| 93 | mode change 100755 => 100644 scripts/canister-decrypt-operator-backup.mjs |
| 94 | mode change 100755 => 100644 scripts/canister-export-backup.mjs |
| 95 | mode change 100755 => 100644 scripts/canister-export-backup.sh |
| 96 | mode change 100755 => 100644 scripts/canister-predeploy.sh |
| 97 | mode change 100755 => 100644 scripts/canister-release-prep.sh |
| 98 | mode change 100755 => 100644 scripts/icp-canister-snapshot-backup.sh |
| 99 | mode change 100755 => 100644 scripts/post-merge-hub-canister-release.sh |
| 100 | mode change 100755 => 100644 scripts/validate-deepinfra-enrich.mjs |
| 101 | mode change 100755 => 100644 scripts/write-to-vault.sh |
| 102 | ``` |
| 103 | |
| 104 | Same blob SHAs on both sides β content identical, modes lost. Merging this PR would have broken the AWS Paperclip installer (`install.sh`) and the canister-snapshot CI workflow on GitHub Actions. Caught during pre-merge `git diff --summary` review. |
| 105 | |
| 106 | ## Proposed fix β heuristic shebang detection at export time |
| 107 | |
| 108 | Smallest viable patch: replace `fix_file_modes` with a shebang-driven heuristic, change `--fix-modes` default to `True`, update docs/help. |
| 109 | |
| 110 | ### Why shebang detection is correct |
| 111 | |
| 112 | - **Universal**: works regardless of file extension (catches `.mjs`, `.py`, no-extension scripts) |
| 113 | - **Authoritative**: shebang is the Unix declaration that "this file is meant to be executed" |
| 114 | - **Zero false positives** in practice: regular text/binary files do not start with `#!` |
| 115 | - **Standard Unix convention**: `binfmt` on Linux uses the same signal |
| 116 | - **No schema change**: snapshot manifest stays `dict[path β object_id]`; `snapshot_id` derivation unchanged; no migration required |
| 117 | |
| 118 | Verified against the 15 affected files in the real-world case above: every one starts with `#!/usr/bin/env bash` or `#!/usr/bin/env node`. 100% recall, 0% false positive (subject to standard heuristic caveats below). |
| 119 | |
| 120 | ### Patch (unified diff against `0.2.0rc7`) |
| 121 | |
| 122 | ```diff |
| 123 | --- a/muse/cli/commands/bridge.py |
| 124 | +++ b/muse/cli/commands/bridge.py |
| 125 | @@ -2066,28 +2066,69 @@ class GitExporter: |
| 126 | |
| 127 | return files_written |
| 128 | |
| 129 | def fix_file_modes(self, manifest: SnapshotManifest) -> None: |
| 130 | - """Set file permissions to 644 for all exported files. |
| 131 | - |
| 132 | - Does not touch ``.git/`` or any path outside ``git_dir``. |
| 133 | + """Set file permissions on exported files based on shebang detection. |
| 134 | + |
| 135 | + Files whose first two bytes are ``#!`` (POSIX shebang) get ``0o755`` |
| 136 | + (read+execute for owner/group/world, write for owner only). All |
| 137 | + other regular files get ``0o644``. |
| 138 | + |
| 139 | + This is a heuristic recovery β Muse's snapshot manifest does not |
| 140 | + currently track POSIX mode bits (see ``core/snapshot.py`` docstring, |
| 141 | + ``snapshot_id`` derivation), so the export side must reconstruct |
| 142 | + executability from file content. Shebang detection is the standard |
| 143 | + Unix convention for "this file is meant to be executed" and matches |
| 144 | + what ``binfmt`` checks at exec time. |
| 145 | + |
| 146 | + Setuid/setgid bits (``04xxx``/``02xxx``) are never set β only the |
| 147 | + canonical ``0o755`` and ``0o644`` are produced. This prevents a |
| 148 | + crafted Muse repo from smuggling escalated permissions through a |
| 149 | + bridge sync. |
| 150 | + |
| 151 | + Does not touch ``.git/`` or any path outside ``git_dir``. |
| 152 | |
| 153 | Args: |
| 154 | manifest: Manifest of exported files (only these paths are touched). |
| 155 | """ |
| 156 | import stat |
| 157 | |
| 158 | + MODE_REGULAR = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH # 0o644 |
| 159 | + MODE_EXECUTABLE = MODE_REGULAR | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH # 0o755 |
| 160 | + |
| 161 | for rel_path in manifest: |
| 162 | dest = self.git_dir / rel_path |
| 163 | if dest.exists() and dest.is_file(): |
| 164 | # Ensure path is inside git_dir (no traversal) |
| 165 | try: |
| 166 | dest.resolve().relative_to(self.git_dir.resolve()) |
| 167 | except ValueError: |
| 168 | continue |
| 169 | # Never touch .git/ |
| 170 | if ".git" in dest.relative_to(self.git_dir).parts: |
| 171 | continue |
| 172 | - dest.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) |
| 173 | + dest.chmod( |
| 174 | + MODE_EXECUTABLE |
| 175 | + if GitExporter._has_shebang(dest) |
| 176 | + else MODE_REGULAR |
| 177 | + ) |
| 178 | + |
| 179 | + @staticmethod |
| 180 | + def _has_shebang(dest: pathlib.Path) -> bool: |
| 181 | + """Return True iff *dest*'s first two bytes are ``#!``. |
| 182 | + |
| 183 | + Read errors return False (treat as non-executable, fail safe). |
| 184 | + Reads only 2 bytes β no file-size dependent cost. |
| 185 | + """ |
| 186 | + try: |
| 187 | + with dest.open("rb") as f: |
| 188 | + head = f.read(2) |
| 189 | + return head == b"#!" |
| 190 | + except OSError: |
| 191 | + return False |
| 192 | ``` |
| 193 | |
| 194 | ```diff |
| 195 | --- a/muse/cli/commands/bridge.py |
| 196 | +++ b/muse/cli/commands/bridge.py |
| 197 | @@ -2898,9 +2898,9 @@ def _register_git_export( |
| 198 | ) |
| 199 | p.add_argument( |
| 200 | "--fix-modes", |
| 201 | - action="store_true", |
| 202 | - default=False, |
| 203 | - help="chmod files after writing (644 for files, 755 for executables).", |
| 204 | + action=argparse.BooleanOptionalAction, |
| 205 | + default=True, |
| 206 | + help="chmod files after writing β 644 for regular files, 755 for files with a POSIX shebang. Disable with --no-fix-modes.", |
| 207 | ) |
| 208 | p.add_argument( |
| 209 | "--dry-run", |
| 210 | ``` |
| 211 | |
| 212 | The `BooleanOptionalAction` switch keeps the behaviour opt-out (`--no-fix-modes`) for any CI that intentionally wants 644-only output, while making the correct behaviour the default for new users. |
| 213 | |
| 214 | ## Tests (per the project's 7-tier convention) |
| 215 | |
| 216 | New file: `tests/cli/commands/test_bridge_fix_modes.py` |
| 217 | |
| 218 | ```python |
| 219 | """Tests for muse bridge git-export executable-bit handling. |
| 220 | |
| 221 | Covers all 7 standard tiers: unit, integration, end-to-end, stress, |
| 222 | data-integrity, performance, security. |
| 223 | """ |
| 224 | import os |
| 225 | import stat |
| 226 | import subprocess |
| 227 | import time |
| 228 | |
| 229 | import pytest |
| 230 | |
| 231 | from muse.cli.commands.bridge import GitExporter |
| 232 | |
| 233 | |
| 234 | # ββ Tier 1: unit βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 235 | class TestHasShebang: |
| 236 | def test_shebang_bash(self, tmp_path): |
| 237 | f = tmp_path / "x.sh" |
| 238 | f.write_bytes(b"#!/usr/bin/env bash\necho hi\n") |
| 239 | assert GitExporter._has_shebang(f) is True |
| 240 | |
| 241 | def test_shebang_node(self, tmp_path): |
| 242 | f = tmp_path / "x.mjs" |
| 243 | f.write_bytes(b"#!/usr/bin/env node\nconsole.log('hi');\n") |
| 244 | assert GitExporter._has_shebang(f) is True |
| 245 | |
| 246 | def test_shebang_python_no_env(self, tmp_path): |
| 247 | f = tmp_path / "x.py" |
| 248 | f.write_bytes(b"#!/usr/bin/python3\nprint('hi')\n") |
| 249 | assert GitExporter._has_shebang(f) is True |
| 250 | |
| 251 | def test_no_shebang_plain_text(self, tmp_path): |
| 252 | f = tmp_path / "readme.md" |
| 253 | f.write_bytes(b"# title\nhello\n") |
| 254 | assert GitExporter._has_shebang(f) is False |
| 255 | |
| 256 | def test_no_shebang_empty_file(self, tmp_path): |
| 257 | f = tmp_path / "empty" |
| 258 | f.write_bytes(b"") |
| 259 | assert GitExporter._has_shebang(f) is False |
| 260 | |
| 261 | def test_no_shebang_one_byte(self, tmp_path): |
| 262 | f = tmp_path / "x" |
| 263 | f.write_bytes(b"#") |
| 264 | assert GitExporter._has_shebang(f) is False |
| 265 | |
| 266 | def test_no_shebang_with_leading_whitespace(self, tmp_path): |
| 267 | # #! must be at byte 0, not after whitespace |
| 268 | f = tmp_path / "x.sh" |
| 269 | f.write_bytes(b" #!/usr/bin/env bash\n") |
| 270 | assert GitExporter._has_shebang(f) is False |
| 271 | |
| 272 | def test_read_error_returns_false(self, tmp_path): |
| 273 | # Path doesn't exist β OSError β False (fail-safe) |
| 274 | assert GitExporter._has_shebang(tmp_path / "nonexistent") is False |
| 275 | |
| 276 | |
| 277 | # ββ Tier 2: integration ββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 278 | class TestFixFileModesAppliesCorrectly: |
| 279 | def test_shebang_file_gets_755(self, tmp_path): |
| 280 | git_dir = tmp_path / "g" |
| 281 | git_dir.mkdir() |
| 282 | (git_dir / "scripts").mkdir() |
| 283 | (git_dir / "scripts" / "run.sh").write_bytes(b"#!/usr/bin/env bash\n") |
| 284 | (git_dir / "README.md").write_bytes(b"# title\n") |
| 285 | |
| 286 | # Build a minimal GitExporter-like object (or use a real one with mocked deps). |
| 287 | # The exact bootstrap depends on GitExporter's __init__ in 0.2.0rc7. |
| 288 | exporter = _build_test_exporter(git_dir) |
| 289 | exporter.fix_file_modes({"scripts/run.sh": "sha256:fake", "README.md": "sha256:fake"}) |
| 290 | |
| 291 | assert (git_dir / "scripts" / "run.sh").stat().st_mode & 0o777 == 0o755 |
| 292 | assert (git_dir / "README.md").stat().st_mode & 0o777 == 0o644 |
| 293 | |
| 294 | def test_dotgit_directory_never_touched(self, tmp_path): |
| 295 | git_dir = tmp_path / "g" |
| 296 | (git_dir / ".git").mkdir(parents=True) |
| 297 | gitfile = git_dir / ".git" / "HEAD" |
| 298 | gitfile.write_bytes(b"ref: refs/heads/main\n") |
| 299 | original_mode = gitfile.stat().st_mode |
| 300 | |
| 301 | exporter = _build_test_exporter(git_dir) |
| 302 | exporter.fix_file_modes({".git/HEAD": "sha256:fake"}) |
| 303 | |
| 304 | # .git/HEAD must remain at its original mode. |
| 305 | assert gitfile.stat().st_mode == original_mode |
| 306 | |
| 307 | |
| 308 | # ββ Tier 3: end-to-end βββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 309 | class TestRoundTripBridgeExport: |
| 310 | """Init Muse repo β commit script β bridge β assert git ls-tree mode.""" |
| 311 | def test_executable_script_roundtrips_as_100755(self, tmp_path): |
| 312 | # See reproducer in issue body. |
| 313 | # Asserts: git ls-tree HEAD shows 100755 for the script. |
| 314 | ... |
| 315 | |
| 316 | |
| 317 | # ββ Tier 4: stress βββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 318 | class TestFixModesStress: |
| 319 | def test_1000_files_mixed(self, tmp_path): |
| 320 | git_dir = tmp_path / "g" |
| 321 | git_dir.mkdir() |
| 322 | manifest = {} |
| 323 | for i in range(500): |
| 324 | f = git_dir / f"script_{i}.sh" |
| 325 | f.write_bytes(b"#!/usr/bin/env bash\n") |
| 326 | manifest[f"script_{i}.sh"] = "sha256:fake" |
| 327 | f2 = git_dir / f"doc_{i}.md" |
| 328 | f2.write_bytes(b"# doc\n") |
| 329 | manifest[f"doc_{i}.md"] = "sha256:fake" |
| 330 | |
| 331 | exporter = _build_test_exporter(git_dir) |
| 332 | start = time.perf_counter() |
| 333 | exporter.fix_file_modes(manifest) |
| 334 | elapsed = time.perf_counter() - start |
| 335 | |
| 336 | for i in range(500): |
| 337 | assert (git_dir / f"script_{i}.sh").stat().st_mode & 0o777 == 0o755 |
| 338 | assert (git_dir / f"doc_{i}.md").stat().st_mode & 0o777 == 0o644 |
| 339 | # 1000 files in <500ms on dev hardware |
| 340 | assert elapsed < 0.5, f"fix_file_modes too slow: {elapsed:.3f}s for 1000 files" |
| 341 | |
| 342 | |
| 343 | # ββ Tier 5: data-integrity βββββββββββββββββββββββββββββββββββββββββββββββ |
| 344 | class TestModeChangesArePersistent: |
| 345 | def test_chmod_minus_x_then_recommit_drops_to_644(self, tmp_path): |
| 346 | # When user removes shebang from a script and re-bridges, |
| 347 | # the new export should produce 0o644 (mode follows content). |
| 348 | ... |
| 349 | |
| 350 | |
| 351 | # ββ Tier 6: performance ββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 352 | class TestPerformance: |
| 353 | def test_no_regression_vs_baseline(self, benchmark, tmp_path): |
| 354 | # pytest-benchmark; baseline is the existing 0o644-only loop. |
| 355 | # Heuristic adds one 2-byte read per file; allowed regression: <5%. |
| 356 | ... |
| 357 | |
| 358 | |
| 359 | # ββ Tier 7: security βββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 360 | class TestSecurityModeNeverEscalatesBeyond755: |
| 361 | def test_setuid_bit_is_never_set(self, tmp_path): |
| 362 | git_dir = tmp_path / "g" |
| 363 | git_dir.mkdir() |
| 364 | f = git_dir / "evil.sh" |
| 365 | f.write_bytes(b"#!/bin/sh\n") |
| 366 | # Pre-set the file to 04755 (setuid). |
| 367 | f.chmod(0o4755) |
| 368 | |
| 369 | exporter = _build_test_exporter(git_dir) |
| 370 | exporter.fix_file_modes({"evil.sh": "sha256:fake"}) |
| 371 | |
| 372 | mode = f.stat().st_mode & 0o7777 # full mode incl. setuid |
| 373 | # Must be exactly 0o755 β setuid bit MUST be cleared. |
| 374 | assert mode == 0o755, f"setuid not cleared: got {oct(mode)}" |
| 375 | |
| 376 | def test_path_traversal_blocked(self, tmp_path): |
| 377 | # Manifest entries that resolve outside git_dir must be skipped. |
| 378 | # Existing protection at lines 2082-2086; verify it still holds. |
| 379 | ... |
| 380 | |
| 381 | def test_symlink_to_external_path_not_chmodded(self, tmp_path): |
| 382 | # Symlinks pointing outside git_dir must be skipped. |
| 383 | ... |
| 384 | |
| 385 | |
| 386 | # ββ Test helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 387 | def _build_test_exporter(git_dir): |
| 388 | """Build a minimal GitExporter for fix_file_modes-only testing. |
| 389 | |
| 390 | fix_file_modes only reads self.git_dir; other GitExporter state is irrelevant. |
| 391 | """ |
| 392 | from unittest.mock import MagicMock |
| 393 | e = MagicMock(spec=GitExporter) |
| 394 | e.git_dir = git_dir |
| 395 | e.fix_file_modes = GitExporter.fix_file_modes.__get__(e, GitExporter) |
| 396 | return e |
| 397 | ``` |
| 398 | |
| 399 | ## Alternatives considered |
| 400 | |
| 401 | 1. **Full mode tracking in snapshot manifest.** Add `mode: int` to manifest entries; include in `snapshot_id` derivation. Most "correct" but requires `format_version` bump, migration script (`muse code migrate` already exists for similar transitions), and breaks backward-compatible `snapshot_id` for all existing repos. Recommend defer to a separate RFC. |
| 402 | 2. **Extend `.museattributes` with an `executable` field.** Idiomatic but requires user opt-in per pattern. Combine with the heuristic above as a future override mechanism if false positives surface in the wild. |
| 403 | 3. **Per-file extension whitelist** (`.sh`, `.bash`, `.mjs`, `.py`, β¦). Fails for shebang-but-no-extension files (common in `bin/` directories). Shebang detection strictly dominates extension matching. |
| 404 | |
| 405 | ## Migration concerns |
| 406 | |
| 407 | - Existing repos: no migration needed. The fix changes export-time behaviour only; on-disk Muse data is untouched. |
| 408 | - Downstream Git remotes: the next `bridge git-export` after this fix will produce a "mode-fix" diff for every file that now correctly gains `100755`. This is a one-time correction; subsequent exports are stable. |
| 409 | - CI/CD using `--fix-modes` explicitly: behaviour now matches what the help text always promised. No user-visible regression β only files with shebangs gain `+x`, which is what those users wanted. |
| 410 | - CI/CD that depends on universal `0o644`: opt out with `--no-fix-modes` (newly available via `BooleanOptionalAction`). |
| 411 | |
| 412 | ## Local verification results (patch already applied + tested) |
| 413 | |
| 414 | Reporter applied the patch above directly to a local `0.2.0rc7` install and re-bridged `aaronrene/knowtation` (canonical `staging/main` HEAD `sha256:8514304e16d7f68db83b5183c01d4b2dd75e976a1cad9c39afa1334debafb110`): |
| 415 | |
| 416 | ``` |
| 417 | === Bridge run === |
| 418 | Exported 613 files β git 'muse-mirror' (committed) |
| 419 | |
| 420 | === File mode distribution in resulting mirror === |
| 421 | 572 100644 |
| 422 | 41 100755 |
| 423 | |
| 424 | === Sample git ls-tree HEAD (8 representative paths) === |
| 425 | 100644 blob 048f180da5ff⦠.gitignore |
| 426 | 100644 blob 2b7a023f95c3β¦ AGENTS.md |
| 427 | 100755 blob 166b87ff3865β¦ deploy/paperclip/install.sh |
| 428 | 100755 blob a98b6b5c6d27β¦ deploy/paperclip/scripts/push-secrets.sh |
| 429 | 100644 blob 06ab00e0f22a⦠docs/AGENT-ORCHESTRATION.md |
| 430 | 100755 blob 08ecbdf403a1β¦ scripts/canister-export-backup.sh |
| 431 | 100755 blob b3a6c97c8eed⦠scripts/validate-deepinfra-enrich.mjs |
| 432 | 100755 blob 0f78690555e8β¦ scripts/write-to-vault.sh |
| 433 | |
| 434 | === Identity check === |
| 435 | PASS 0755 deploy/paperclip/install.sh |
| 436 | PASS 0755 deploy/paperclip/scripts/hello-world-test.sh |
| 437 | PASS 0755 deploy/paperclip/scripts/load-skills-and-agents.sh |
| 438 | PASS 0755 deploy/paperclip/scripts/push-secrets.sh |
| 439 | PASS 0755 deploy/paperclip/scripts/run-controller.sh |
| 440 | PASS 0755 deploy/paperclip/scripts/wire-knowtation-mcp.sh |
| 441 | PASS 0755 scripts/canister-decrypt-operator-backup.mjs |
| 442 | PASS 0755 scripts/canister-export-backup.mjs |
| 443 | PASS 0755 scripts/canister-export-backup.sh |
| 444 | PASS 0755 scripts/canister-predeploy.sh |
| 445 | PASS 0755 scripts/canister-release-prep.sh |
| 446 | PASS 0755 scripts/icp-canister-snapshot-backup.sh |
| 447 | PASS 0755 scripts/post-merge-hub-canister-release.sh |
| 448 | PASS 0755 scripts/validate-deepinfra-enrich.mjs |
| 449 | PASS 0755 scripts/write-to-vault.sh |
| 450 | |
| 451 | Summary: 15/15 PASS, 0 FAIL, 0 MISSING |
| 452 | ``` |
| 453 | |
| 454 | **Notable:** the patch caught **41 executables** total, not just the 15 originally identified during the diff review. Twenty-six additional files in the canonical `staging/main` had been silently stripped of their executable bit by previous bridge runs and were correctly restored on this run. Real-world impact is broader than the original triage suggested. |
| 455 | |
| 456 | After confirming the patch works, reporter rolled back the local venv to canonical `0.2.0rc7` so subsequent operations exercise the unpatched (current) code path until this fix lands officially. |
| 457 | |
| 458 | --- |
| 459 | |
| 460 | ## Suggested labels |
| 461 | |
| 462 | `bug` Β· `bridge` Β· `severity-high` Β· `good-first-fix-after-design-ack` |
| 463 | |
| 464 | ## Related |
| 465 | |
| 466 | - Issue #36 (resolved): `compute_commit_id` formula divergence between client and server. Same kind of "client expects feature, server didn't ship it" mismatch β except here it's "feature documented, never implemented." |