Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ jobs:
- name: Validate Python test harness syntax
run: git ls-files 'tests/*.py' 'tests_v2/*.py' 'scripts/*.py' | xargs python3 -m py_compile

- name: Validate TUI npm package artifact transfer
run: python3 tests/test_tui_npm_package_artifact.py

- name: Validate Claude launch environment policy generation
run: python3 scripts/generate-claude-launch-environment-policy.py --check

Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/cmux-tui-build-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,13 @@ jobs:
dist/binaries/cmux-tui-x86_64-unknown-linux-gnu --version >/tmp/cmux-tui-version.txt 2>&1 || \
dist/binaries/cmux-tui-x86_64-unknown-linux-gnu --help >/tmp/cmux-tui-version.txt 2>&1

- name: Archive npm package directories with executable modes
if: inputs.package_npm
run: |
python3 cmux-tui/dist/scripts/package_npm_artifact.py create \
--packages-dir dist/npm-packages \
--archive dist/npm-packages.tar.gz

- name: Smoke verify PyPI wheels
if: inputs.package_pypi
run: |
Expand All @@ -312,7 +319,7 @@ jobs:
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: npm-packages
path: dist/npm-packages
path: dist/npm-packages.tar.gz
if-no-files-found: error

- name: Upload PyPI wheels
Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/cmux-tui-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,23 @@ jobs:
name: npm-tui
url: https://www.npmjs.com/package/cmux
steps:
- name: Checkout immutable nightly source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ needs.version.outputs.head_sha }}

- name: Download npm package directories
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: npm-packages
path: dist/npm-packages
path: dist

- name: Restore npm package executable modes
run: |
python3 cmux-tui/dist/scripts/package_npm_artifact.py extract \
--archive dist/npm-packages.tar.gz \
--out dist

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/tui-publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,13 @@ jobs:
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: npm-packages
path: dist/npm-packages
path: dist

- name: Restore npm package executable modes
run: |
python3 cmux-tui/dist/scripts/package_npm_artifact.py extract \
--archive dist/npm-packages.tar.gz \
--out dist

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
Expand Down
116 changes: 116 additions & 0 deletions cmux-tui/dist/scripts/package_npm_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Transfer generated npm package directories without losing executable modes."""

from __future__ import annotations

import argparse
import stat
import tarfile
from pathlib import Path, PurePosixPath


PACKAGE_ROOT = "npm-packages"
EXECUTABLES = (
"cmux-tui-darwin-arm64/bin/cmux-tui",
"cmux-tui-darwin-x64/bin/cmux-tui",
"cmux-tui-linux-x64/bin/cmux-tui",
"cmux-tui-linux-arm64/bin/cmux-tui",
"cmux/bin/cmux.js",
)
MAX_MEMBERS = 1_024
MAX_EXPANDED_BYTES = 512 * 1024 * 1024


def verify_executables(packages_dir: Path) -> None:
if not packages_dir.is_dir():
raise SystemExit(f"missing npm package directory: {packages_dir}")
if packages_dir.name != PACKAGE_ROOT:
raise SystemExit(f"npm package directory must be named {PACKAGE_ROOT}")

for relative_path in EXECUTABLES:
executable = packages_dir / relative_path
if not executable.is_file():
raise SystemExit(f"missing npm package executable: {executable}")
if not executable.stat().st_mode & stat.S_IXUSR:
raise SystemExit(f"npm package entry is not executable: {executable}")


def create_archive(packages_dir: Path, archive: Path) -> None:
packages_dir = packages_dir.resolve()
archive = archive.resolve()
verify_executables(packages_dir)
if archive == packages_dir or packages_dir in archive.parents:
raise SystemExit("npm package archive must be outside the package directory")
archive.parent.mkdir(parents=True, exist_ok=True)
if archive.exists():
archive.unlink()
with tarfile.open(archive, "w:gz") as output:
output.add(packages_dir, arcname=PACKAGE_ROOT, recursive=True)


def validated_members(archive: tarfile.TarFile) -> list[tarfile.TarInfo]:
members = archive.getmembers()
if not members:
raise SystemExit("npm package archive is empty")
if len(members) > MAX_MEMBERS:
raise SystemExit("npm package archive has too many entries")

seen: set[str] = set()
expanded_bytes = 0
for member in members:
path = PurePosixPath(member.name)
if path.is_absolute() or ".." in path.parts:
raise SystemExit(f"unsafe npm package archive path: {member.name}")
if not path.parts or path.parts[0] != PACKAGE_ROOT:
raise SystemExit(f"npm package archive entry is outside {PACKAGE_ROOT}")
normalized_name = path.as_posix()
if normalized_name in seen:
raise SystemExit(f"duplicate npm package archive entry: {member.name}")
seen.add(normalized_name)
if not (member.isfile() or member.isdir()):
raise SystemExit(f"unsupported npm package archive entry: {member.name}")
member.mode &= 0o777
expanded_bytes += member.size
if expanded_bytes > MAX_EXPANDED_BYTES:
raise SystemExit("npm package archive exceeds expanded size limit")
return members


def extract_archive(archive_path: Path, out_dir: Path) -> None:
archive_path = archive_path.resolve()
out_dir = out_dir.resolve()
packages_dir = out_dir / PACKAGE_ROOT
if packages_dir.exists():
raise SystemExit(f"refusing to replace existing directory: {packages_dir}")
out_dir.mkdir(parents=True, exist_ok=True)

with tarfile.open(archive_path, "r:gz") as archive:
members = validated_members(archive)
archive.extractall(out_dir, members=members, filter="tar")
verify_executables(packages_dir)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)

create = subparsers.add_parser("create")
create.add_argument("--packages-dir", required=True, type=Path)
create.add_argument("--archive", required=True, type=Path)

extract = subparsers.add_parser("extract")
extract.add_argument("--archive", required=True, type=Path)
extract.add_argument("--out", required=True, type=Path)
return parser.parse_args()


def main() -> None:
args = parse_args()
if args.command == "create":
create_archive(args.packages_dir, args.archive)
else:
extract_archive(args.archive, args.out)


if __name__ == "__main__":
main()
96 changes: 96 additions & 0 deletions tests/test_tui_npm_package_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from __future__ import annotations

import shutil
import stat
import subprocess
import sys
import tempfile
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "cmux-tui/dist/scripts/package_npm_artifact.py"
EXECUTABLES = (
"cmux-tui-darwin-arm64/bin/cmux-tui",
"cmux-tui-darwin-x64/bin/cmux-tui",
"cmux-tui-linux-x64/bin/cmux-tui",
"cmux-tui-linux-arm64/bin/cmux-tui",
"cmux/bin/cmux.js",
)


def run_helper(*args: object) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), *(str(arg) for arg in args)],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)


def test_archive_round_trip_preserves_package_executables(tmp_path: Path) -> None:
Comment thread
lawrencecchen marked this conversation as resolved.
Comment thread
lawrencecchen marked this conversation as resolved.
packages = tmp_path / "npm-packages"
for relative_path in EXECUTABLES:
executable = packages / relative_path
executable.parent.mkdir(parents=True, exist_ok=True)
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)

archive = tmp_path / "npm-packages.tar.gz"
created = run_helper(
"create", "--packages-dir", packages, "--archive", archive
)
assert created.returncode == 0, created.stderr

# GitHub artifact transfer may normalize the outer file mode. The archive
# must still restore executable package entries after download.
archive.chmod(0o644)
shutil.rmtree(packages)

extracted = run_helper("extract", "--archive", archive, "--out", tmp_path)
assert extracted.returncode == 0, extracted.stderr
for relative_path in EXECUTABLES:
mode = (packages / relative_path).stat().st_mode
assert mode & stat.S_IXUSR, relative_path


def test_extract_rejects_paths_outside_package_root(tmp_path: Path) -> None:
archive = tmp_path / "npm-packages.tar.gz"
# Build the hostile archive without relying on the helper under test.
import io
import tarfile

with tarfile.open(archive, "w:gz") as tar:
info = tarfile.TarInfo("../outside")
contents = b"unexpected"
info.size = len(contents)
tar.addfile(info, io.BytesIO(contents))

extracted = run_helper("extract", "--archive", archive, "--out", tmp_path)
assert extracted.returncode != 0
assert not (tmp_path.parent / "outside").exists()


def test_publish_workflows_restore_the_mode_preserving_archive() -> None:
build = (ROOT / ".github/workflows/cmux-tui-build-package.yml").read_text()
stable = (ROOT / ".github/workflows/tui-publish-npm.yml").read_text()
nightly = (ROOT / ".github/workflows/cmux-tui-nightly.yml").read_text()

assert "package_npm_artifact.py create" in build
assert "path: dist/npm-packages.tar.gz" in build
for workflow in (stable, nightly):
assert "package_npm_artifact.py extract" in workflow
assert "--archive dist/npm-packages.tar.gz" in workflow


def main() -> None:
with tempfile.TemporaryDirectory() as directory:
test_archive_round_trip_preserves_package_executables(Path(directory))
with tempfile.TemporaryDirectory() as directory:
test_extract_rejects_paths_outside_package_root(Path(directory))
test_publish_workflows_restore_the_mode_preserving_archive()


if __name__ == "__main__":
main()