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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified codex-plugin/sourcebraid/assets/sourcebraid-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified icons/icon-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
139 changes: 139 additions & 0 deletions scripts/build_chrome_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Build a privacy-safe Chrome Web Store ZIP from an explicit allowlist."""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
import zipfile
from pathlib import Path, PurePosixPath


PACKAGE_FILES = (
".github/workflows/convert-pdfs.yml",
"background.js",
"capture-utils.js",
"content.js",
"icons/icon-16.png",
"icons/icon-32.png",
"icons/icon-48.png",
"icons/icon-128.png",
"manifest.json",
"popup.css",
"popup.html",
"popup.js",
"requirements-docling.txt",
"scripts/convert_pdfs.py",
"scripts/push_with_retry.py",
)

VERSION_PATTERN = re.compile(r"^(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*)){0,3}$")


class PackageError(RuntimeError):
"""Raised when a safe release archive cannot be created."""


def validated_manifest(repository_root: Path) -> dict[str, object]:
manifest_path = repository_root / "manifest.json"
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise PackageError(f"could not read manifest.json: {error}") from error

if manifest.get("manifest_version") != 3:
raise PackageError("manifest.json must use Manifest V3")
if manifest.get("name") != "SourceBraid":
raise PackageError("manifest.json must identify the extension as SourceBraid")

version = manifest.get("version")
if not isinstance(version, str) or not VERSION_PATTERN.fullmatch(version):
raise PackageError("manifest.json contains an invalid Chrome extension version")
return manifest


def validated_package_files(repository_root: Path) -> list[tuple[Path, str]]:
files: list[tuple[Path, str]] = []
for archive_name in PACKAGE_FILES:
relative = PurePosixPath(archive_name)
if relative.is_absolute() or ".." in relative.parts:
raise PackageError(f"unsafe package path: {archive_name}")
source = repository_root.joinpath(*relative.parts)
if source.is_symlink():
raise PackageError(f"refusing to package symlink: {archive_name}")
if not source.is_file():
raise PackageError(f"required extension file is missing: {archive_name}")
files.append((source, relative.as_posix()))
return files


def build_package(repository_root: Path, output_path: Path) -> tuple[str, str]:
manifest = validated_manifest(repository_root)
files = validated_package_files(repository_root)
output_path.parent.mkdir(parents=True, exist_ok=True)

with zipfile.ZipFile(
output_path,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for source, archive_name in files:
info = zipfile.ZipInfo(archive_name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
archive.writestr(info, source.read_bytes())

with zipfile.ZipFile(output_path) as archive:
packaged_names = tuple(sorted(archive.namelist()))
expected_names = tuple(sorted(PACKAGE_FILES))
if packaged_names != expected_names:
output_path.unlink(missing_ok=True)
raise PackageError("release archive does not match the explicit allowlist")

digest = hashlib.sha256(output_path.read_bytes()).hexdigest()
return str(manifest["version"]), digest


def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(
description="Build the SourceBraid Chrome Web Store ZIP from an explicit allowlist.",
)
result.add_argument(
"--repository-root",
type=Path,
default=Path(__file__).resolve().parents[1],
help="SourceBraid repository root (defaults to the parent of scripts/).",
)
result.add_argument(
"--output",
type=Path,
help="Output ZIP path (defaults to dist/sourcebraid-chrome-vVERSION.zip).",
)
return result


def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
repository_root = args.repository_root.resolve()
try:
manifest = validated_manifest(repository_root)
version = str(manifest["version"])
output = (args.output or repository_root / "dist" / f"sourcebraid-chrome-v{version}.zip").resolve()
version, digest = build_package(repository_root, output)
except PackageError as error:
print(f"error: {error}", file=sys.stderr)
return 1

print(f"package: {output}")
print(f"version: {version}")
print(f"sha256: {digest}")
print(f"files: {len(PACKAGE_FILES)} (explicit allowlist)")
return 0


if __name__ == "__main__":
raise SystemExit(main())
143 changes: 143 additions & 0 deletions scripts/build_plugin_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Build the public skills-only SourceBraid plugin package."""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
import zipfile
from pathlib import Path, PurePosixPath


PLUGIN_FILES = (
"assets/chrome-capture.png",
"assets/codex-search.png",
"assets/private-markdown-archive.png",
"assets/sourcebraid-icon.png",
"scripts/sourcebraid.py",
"skills/sourcebraid-delete/SKILL.md",
"skills/sourcebraid-delete/agents/openai.yaml",
"skills/sourcebraid-index/SKILL.md",
"skills/sourcebraid-index/agents/openai.yaml",
"skills/sourcebraid-search/SKILL.md",
"skills/sourcebraid-search/agents/openai.yaml",
)

VERSION_PATTERN = re.compile(
r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
r"(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
)


class PluginPackageError(RuntimeError):
"""Raised when the public plugin package cannot be built safely."""


def public_manifest(plugin_root: Path) -> dict[str, object]:
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise PluginPackageError(f"could not read plugin manifest: {error}") from error
if manifest.get("name") != "sourcebraid":
raise PluginPackageError("plugin manifest name must be sourcebraid")
version = manifest.get("version")
if not isinstance(version, str) or not VERSION_PATTERN.fullmatch(version):
raise PluginPackageError("plugin manifest contains an invalid semantic version")
if manifest.get("skills") != "./skills/":
raise PluginPackageError("plugin manifest must point skills at ./skills/")

# The first public release is deliberately skills-only. The local source
# package keeps its bundled stdio MCP server for development and repo use.
manifest.pop("mcpServers", None)
manifest.pop("apps", None)
return manifest


def validated_files(plugin_root: Path) -> list[tuple[Path, str]]:
result: list[tuple[Path, str]] = []
for archive_name in PLUGIN_FILES:
relative = PurePosixPath(archive_name)
source = plugin_root.joinpath(*relative.parts)
if source.is_symlink():
raise PluginPackageError(f"refusing to package symlink: {archive_name}")
if not source.is_file():
raise PluginPackageError(f"required plugin file is missing: {archive_name}")
result.append((source, relative.as_posix()))
return result


def build_package(plugin_root: Path, output_path: Path) -> tuple[str, str]:
manifest = public_manifest(plugin_root)
files = validated_files(plugin_root)
manifest_bytes = (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
output_path.parent.mkdir(parents=True, exist_ok=True)

with zipfile.ZipFile(
output_path,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
entries = [(None, ".codex-plugin/plugin.json", manifest_bytes)] + [
(source, archive_name, None) for source, archive_name in files
]
for source, archive_name, generated in entries:
info = zipfile.ZipInfo(archive_name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
archive.writestr(info, generated if generated is not None else source.read_bytes())

expected = tuple(sorted((".codex-plugin/plugin.json", *PLUGIN_FILES)))
with zipfile.ZipFile(output_path) as archive:
actual = tuple(sorted(archive.namelist()))
packaged_manifest = json.loads(archive.read(".codex-plugin/plugin.json"))
if actual != expected or "mcpServers" in packaged_manifest or "apps" in packaged_manifest:
output_path.unlink(missing_ok=True)
raise PluginPackageError("public plugin archive failed the skills-only allowlist check")

digest = hashlib.sha256(output_path.read_bytes()).hexdigest()
return str(manifest["version"]), digest


def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(
description="Build the skills-only SourceBraid package for public plugin submission.",
)
result.add_argument(
"--plugin-root",
type=Path,
default=Path(__file__).resolve().parents[1] / "codex-plugin" / "sourcebraid",
)
result.add_argument("--output", type=Path)
return result


def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
plugin_root = args.plugin_root.resolve()
try:
manifest = public_manifest(plugin_root)
version = str(manifest["version"])
safe_version = version.replace("+", "-")
output = (
args.output
or plugin_root.parents[1] / "dist" / f"sourcebraid-plugin-skills-v{safe_version}.zip"
).resolve()
version, digest = build_package(plugin_root, output)
except PluginPackageError as error:
print(f"error: {error}", file=sys.stderr)
return 1

print(f"package: {output}")
print(f"version: {version}")
print("type: skills-only")
print(f"sha256: {digest}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading