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()