diff --git a/README.md b/README.md index 4af2d1bfe4..634f692a4a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,16 @@ Nix packages for AI coding agents and development tools. Automatically updated d - **Usage**: `nix run github:numtide/llm-agents.nix#bb-app -- --help` - **Nix**: [packages/bb-app/package.nix](packages/bb-app/package.nix) + +
+chatgpt - Desktop application for ChatGPT and Codex + +- **Source**: binary +- **License**: unfree +- **Homepage**: https://developers.openai.com/codex/app +- **Usage**: `nix run github:numtide/llm-agents.nix#chatgpt -- --help` +- **Nix**: [packages/chatgpt/package.nix](packages/chatgpt/package.nix) +
claude-code - Agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster diff --git a/lib/default.nix b/lib/default.nix index d46a5eb5ca..d751efd94a 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -214,6 +214,11 @@ inputs."nixpkgs".lib.extend ( githubId = 20773762; name = "JachinShen"; }; + whazor = { + github = "whazor"; + githubId = 184182; + name = "Nanne"; + }; }; } ) diff --git a/packages/chatgpt/hashes.json b/packages/chatgpt/hashes.json new file mode 100644 index 0000000000..a674909f26 --- /dev/null +++ b/packages/chatgpt/hashes.json @@ -0,0 +1,14 @@ +{ + "sources": { + "aarch64-linux": { + "version": "26.810.50856", + "url": "https://persistent.oaistatic.com/codex-app-prod/linux/deb/pool/main/c/chatgpt/chatgpt_26.810.50856_arm64.deb", + "hash": "sha256-q4UbLrczdCKfjaoXmT0GydAxCU0O6sXn9OAsByoRD2I=" + }, + "x86_64-linux": { + "version": "26.810.50856", + "url": "https://persistent.oaistatic.com/codex-app-prod/linux/deb/pool/main/c/chatgpt/chatgpt_26.810.50856_amd64.deb", + "hash": "sha256-47R8EpjgHkoqpU8SDrFpg0xpEb0pUSK8Q+XNFkLBpLo=" + } + } +} diff --git a/packages/chatgpt/openai-archive-key.asc b/packages/chatgpt/openai-archive-key.asc new file mode 100644 index 0000000000..cc1c7cccdf --- /dev/null +++ b/packages/chatgpt/openai-archive-key.asc @@ -0,0 +1,28 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8 +3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd +GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe +hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw +lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J +vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k +4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/ +7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9 +og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9 +WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W +jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB +tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo +SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ +SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev +SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe +IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF +VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo +pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89 +6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu +i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS +uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E +I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV +8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712 +Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno= +=Y+3h +-----END PGP PUBLIC KEY BLOCK----- diff --git a/packages/chatgpt/package.nix b/packages/chatgpt/package.nix new file mode 100644 index 0000000000..9dd0c63514 --- /dev/null +++ b/packages/chatgpt/package.nix @@ -0,0 +1,192 @@ +{ + lib, + flake, + stdenv, + fetchurl, + coreutils, + dpkg, + formatelf, + makeWrapper, + python3, + wrapGAppsHook3, + alsa-lib, + at-spi2-atk, + at-spi2-core, + atk, + cairo, + cups, + dbus, + expat, + fontconfig, + freetype, + gdk-pixbuf, + glib, + gtk3, + libGL, + libdrm, + libgbm, + libnotify, + libpulseaudio, + libsecret, + libusb1, + libxkbcommon, + nspr, + nss, + pango, + pipewire, + qt5, + qt6, + systemd, + wayland, + xdg-utils, + libx11, + libxcomposite, + libxdamage, + libxext, + libxfixes, + libxrandr, + libxcb, +}: + +let + sourceData = builtins.fromJSON (builtins.readFile ./hashes.json); + platform = stdenv.hostPlatform.system; + source = sourceData.sources.${platform} or (throw "Unsupported system: ${platform}"); +in +stdenv.mkDerivation { + pname = "chatgpt"; + inherit (source) version; + + src = fetchurl { + inherit (source) url hash; + }; + + dontStrip = true; + dontWrapGApps = true; + + nativeBuildInputs = [ + formatelf + dpkg + makeWrapper + python3 + wrapGAppsHook3 + ]; + + buildInputs = [ + alsa-lib + at-spi2-atk + at-spi2-core + atk + cairo + cups + dbus + expat + fontconfig + freetype + gdk-pixbuf + glib + gtk3 + libGL + libdrm + libgbm + libnotify + libpulseaudio + libusb1 + libxkbcommon + nspr + nss + pango + pipewire + stdenv.cc.cc.lib + systemd + wayland + libx11 + libxcomposite + libxdamage + libxext + libxfixes + libxrandr + libxcb + ]; + + # Electron loads these at runtime rather than linking them directly. Put + # them on each ELF object's RPATH without leaking a broad LD_LIBRARY_PATH + # into Electron's Node and Chromium children. + runtimeDependencies = [ + libGL + libgbm + libsecret + pipewire + wayland + ]; + + # The archive includes musl, glibc, and Android prebuilds for a few Node + # modules. NixOS uses the glibc variants, so the other runtimes are + # intentionally absent. + # The Qt shims are optional and selected dynamically, so autoPatchelf cannot + # resolve both of their runtimes during its direct dependency pass. Their + # version-specific RPATHs are added in postFixup below. + autoPatchelfIgnoreMissingDeps = [ + "libc++_shared.so" + "libc.musl-x86_64.so.1" + "liblog.so" + "libQt5Core.so.5" + "libQt5Gui.so.5" + "libQt5Widgets.so.5" + "libQt6Core.so.6" + "libQt6Gui.so.6" + "libQt6Widgets.so.6" + ]; + + unpackPhase = '' + runHook preUnpack + dpkg-deb -x "$src" . + runHook postUnpack + ''; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/bin" "$out/lib" "$out/share" + cp -r usr/lib/chatgpt "$out/lib/" + cp -r usr/share/applications usr/share/pixmaps "$out/share/" + ln -s ../lib/chatgpt/codex-launcher "$out/bin/chatgpt" + + # See patch-asar.py for the NixOS-specific source patches. + python3 ${./patch-asar.py} "$out/lib/chatgpt/resources/app.asar" + + wrapProgram "$out/lib/chatgpt/ChatGPT" \ + "''${gappsWrapperArgs[@]}" \ + --prefix PATH : ${ + lib.makeBinPath [ + coreutils + xdg-utils + ] + } + + runHook postInstall + ''; + + postFixup = '' + patchelf --add-rpath ${lib.makeLibraryPath [ qt5.qtbase ]} \ + "$out/lib/chatgpt/libqt5_shim.so" + patchelf --add-rpath ${lib.makeLibraryPath [ qt6.qtbase ]} \ + "$out/lib/chatgpt/libqt6_shim.so" + ''; + + passthru.category = "AI Coding Agents"; + + meta = with lib; { + description = "Desktop application for ChatGPT and Codex"; + homepage = "https://developers.openai.com/codex/app"; + changelog = "https://learn.chatgpt.com/docs/changelog"; + license = flake.lib.licenses.unfree; + sourceProvenance = with sourceTypes; [ binaryNativeCode ]; + maintainers = with flake.lib.maintainers; [ whazor ]; + mainProgram = "chatgpt"; + platforms = [ + "x86_64-linux" + "aarch64-linux" + ]; + }; +} diff --git a/packages/chatgpt/patch-asar.py b/packages/chatgpt/patch-asar.py new file mode 100755 index 0000000000..a3b7810d8b --- /dev/null +++ b/packages/chatgpt/patch-asar.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Apply byte-length-preserving source patches inside app.asar. + +The asar header records file offsets, so every replacement is padded with +spaces to the original's exact byte length instead of re-packing the archive. +""" + +import sys +from pathlib import Path + +# @parcel/watcher uses detect-libc in a named worker. Its process.report +# fallback trips a CFI guard in the bundled Owl/Electron runtime on NixOS. +# detect-libc falls back to its ELF/filesystem/ldd probes instead. +SKIP_PROCESS_REPORT = ( + b"isLinux() && process.report", + b"false /* nix:skip report */", +) + +# The app materializes bundled plugins in ~/.codex and rewrites selected +# manifests there. Node's fs.cp preserves the Nix store's read-only modes, +# so copy with coreutils and make only the user-owned destination writable. +COPY_PLUGINS_WRITABLE = ( + b'async function Mne(e,t){if(S.default.platform===`darwin`){await lne(`/usr/bin/ditto`,[`--noqtn`,e,t]);return}if(S.default.platform!==`win32`){await y.default.cp(e,t,{recursive:!0,verbatimSymlinks:!0});return}let{copyDirectoryAllowDecryptedDestinationOnEncryptionFailure:n}=await Promise.resolve().then(()=>require("./windows-file-copy-Bw9CB6bJ.js"));await n({copy:()=>y.default.cp(e,t,{recursive:!0,verbatimSymlinks:!0}),destination:t,source:e})}', + b'async function Mne(e,t){let r=S.default.platform;if(r===`darwin`){await lne(`/usr/bin/ditto`,[`--noqtn`,e,t]);return}if(r!==`win32`){await lne(`cp`,[`-r`,e+`/.`,t]);await lne(`chmod`,[`-R`,`u+w`,t]);return}let{copyDirectoryAllowDecryptedDestinationOnEncryptionFailure:n}=await Promise.resolve().then(()=>require("./windows-file-copy-Bw9CB6bJ.js"));await n({copy:()=>y.default.cp(e,t,{recursive:!0,verbatimSymlinks:!0}),destination:t,source:e})}', +) + + +def main() -> None: + """Patch the asar archive given as the only argument.""" + asar = Path(sys.argv[1]) + data = asar.read_bytes() + for original, replacement in (SKIP_PROCESS_REPORT, COPY_PLUGINS_WRITABLE): + if len(replacement) > len(original): + sys.exit(f"replacement longer than original: {replacement[:60]!r}...") + if original not in data: + sys.exit(f"pattern not found in {asar}: {original[:60]!r}...") + data = data.replace(original, replacement.ljust(len(original), b" ")) + asar.write_bytes(data) + + +if __name__ == "__main__": + main() diff --git a/packages/chatgpt/update.py b/packages/chatgpt/update.py new file mode 100755 index 0000000000..b3243a8eae --- /dev/null +++ b/packages/chatgpt/update.py @@ -0,0 +1,218 @@ +#!/usr/bin/env nix +#! nix shell --inputs-from .# nixpkgs#gnupg nixpkgs#python3 --command python3 +"""Update ChatGPT from OpenAI's signed Debian repository.""" + +import hashlib +import subprocess +import sys +import tempfile +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "scripts")) + +from updater import load_hashes, save_hashes, should_update +from updater.hash import hex_to_sri + +PACKAGE_DIR = Path(__file__).parent +HASHES_FILE = PACKAGE_DIR / "hashes.json" +KEY_FILE = PACKAGE_DIR / "openai-archive-key.asc" +KEY_FINGERPRINT = "3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4" +REPO_BASE = "https://persistent.oaistatic.com/codex-app-prod/linux/deb" +INRELEASE_PATH = "dists/stable/InRelease" +PLATFORMS = { + "aarch64-linux": "arm64", + "x86_64-linux": "amd64", +} +USER_AGENT = ( + "llm-agents.nix package updater (+https://github.com/numtide/llm-agents.nix)" +) + + +def fetch(path: str) -> bytes: + """Fetch one path from OpenAI's Debian repository.""" + request = urllib.request.Request( + f"{REPO_BASE}/{path}", + headers={"User-Agent": USER_AGENT}, + ) + with urllib.request.urlopen(request) as response: + return bytes(response.read()) + + +def verify_inrelease(inrelease: bytes) -> str: + """Verify InRelease with the pinned OpenAI key and return its payload.""" + with tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + inrelease_file = temporary / "InRelease" + release_file = temporary / "Release" + keyring_file = temporary / "openai-archive-key.gpg" + inrelease_file.write_bytes(inrelease) + + subprocess.run( + [ + "gpg", + "--batch", + "--dearmor", + "--output", + str(keyring_file), + str(KEY_FILE), + ], + check=True, + capture_output=True, + text=True, + ) + verification = subprocess.run( + [ + "gpgv", + "--keyring", + str(keyring_file), + "--status-fd", + "1", + "--output", + str(release_file), + str(inrelease_file), + ], + check=False, + capture_output=True, + text=True, + ) + if verification.returncode != 0: + msg = f"OpenAI InRelease signature verification failed:\n{verification.stderr}" + raise RuntimeError(msg) + + valid_fingerprints = { + fields[2] + for line in verification.stdout.splitlines() + if (fields := line.split())[:2] == ["[GNUPG:]", "VALIDSIG"] + } + if KEY_FINGERPRINT not in valid_fingerprints: + msg = "OpenAI InRelease was not signed by the pinned key" + raise RuntimeError(msg) + + return release_file.read_text() + + +def release_sha256(release: str, wanted_path: str) -> tuple[str, int]: + """Read the signed SHA256 and size for one repository index.""" + in_sha256 = False + for line in release.splitlines(): + if line == "SHA256:": + in_sha256 = True + continue + if in_sha256 and not line.startswith(" "): + break + if in_sha256: + digest, size, path = line.split() + if path == wanted_path: + return digest, int(size) + + msg = f"{wanted_path} missing from signed InRelease SHA256 section" + raise ValueError(msg) + + +def verify_index(index: bytes, expected_hash: str, expected_size: int) -> None: + """Verify a Packages index against its signed Release metadata.""" + if len(index) != expected_size: + msg = f"Packages size mismatch: expected {expected_size}, got {len(index)}" + raise ValueError(msg) + actual_hash = hashlib.sha256(index).hexdigest() + if actual_hash != expected_hash: + msg = f"Packages SHA256 mismatch: expected {expected_hash}, got {actual_hash}" + raise ValueError(msg) + + +def parse_packages(packages: str) -> list[dict[str, str]]: + """Parse Debian control paragraphs from a Packages index.""" + records: list[dict[str, str]] = [] + for paragraph in packages.strip().split("\n\n"): + record: dict[str, str] = {} + for line in paragraph.splitlines(): + if line.startswith((" ", "\t")): + continue + key, separator, value = line.partition(":") + if separator: + record[key] = value.strip() + records.append(record) + return records + + +def source_from_index(platform: str, architecture: str, release: str) -> dict[str, str]: + """Return one platform source authenticated by the signed APT indexes.""" + index_path = f"main/binary-{architecture}/Packages" + expected_hash, expected_size = release_sha256(release, index_path) + packages = fetch(f"dists/stable/{index_path}") + verify_index(packages, expected_hash, expected_size) + + record = next( + ( + candidate + for candidate in parse_packages(packages.decode()) + if candidate.get("Package") == "chatgpt" + and candidate.get("Architecture") == architecture + ), + None, + ) + if record is None: + msg = f"chatgpt ({architecture}) missing from {index_path}" + raise ValueError(msg) + + required_fields = ("Version", "Filename", "SHA256") + missing_fields = [field for field in required_fields if field not in record] + if missing_fields: + msg = f"chatgpt ({architecture}) missing fields: {', '.join(missing_fields)}" + raise ValueError(msg) + + filename = record["Filename"] + if not filename.startswith("pool/") or ".." in Path(filename).parts: + msg = f"unsafe package filename in signed index: {filename}" + raise ValueError(msg) + + if len(bytes.fromhex(record["SHA256"])) != hashlib.sha256().digest_size: + msg = f"invalid package SHA256 in signed index for {platform}" + raise ValueError(msg) + + return { + "version": record["Version"], + "url": f"{REPO_BASE}/{filename}", + "hash": hex_to_sri(record["SHA256"]), + } + + +def main() -> None: + """Refresh all sources from OpenAI's signed APT metadata.""" + release = verify_inrelease(fetch(INRELEASE_PATH)) + sources = { + platform: source_from_index(platform, architecture, release) + for platform, architecture in PLATFORMS.items() + } + + versions = {source["version"] for source in sources.values()} + if len(versions) != 1: + msg = f"OpenAI architecture versions differ: {sorted(versions)}" + raise ValueError(msg) + + current = load_hashes(HASHES_FILE) + current_sources = current.get("sources", {}) + for platform, source in sources.items(): + current_version = current_sources.get(platform, {}).get("version", "") + if ( + current_version + and source["version"] != current_version + and not should_update(current_version, source["version"]) + ): + msg = ( + f"refusing to downgrade {platform} from {current_version} " + f"to {source['version']}" + ) + raise ValueError(msg) + + if current_sources == sources: + print("chatgpt: already up to date") + return + + save_hashes(HASHES_FILE, {"sources": sources}) + print(f"chatgpt: updated to {versions.pop()}") + + +if __name__ == "__main__": + main()