diff options
| author | Craig Jennings <c@cjennings.net> | 2026-10-12 01:19:10 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-10-12 01:19:10 -0500 |
| commit | e530fd597b62bda520f9804be7da216eea582750 (patch) | |
| tree | 3d356e371120bd3f64d2af01daecb88a219abdcb /tests/installer-steps/test_configure_openai_chatgpt_repo.py | |
| parent | a6a66ebcff40ea95bbda5aa8f890850ca87e822b (diff) | |
| download | archsetup-main.tar.gz archsetup-main.zip | |
OpenAI publishes the ChatGPT (Codex) desktop app for Arch as chatgpt-bin from its own signed pacman repository, and that's how ratio and velox got it on 2026-10-11. configure_openai_chatgpt_repo reproduces that setup. It fetches the repository signing key and accepts it only when the primary fingerprint matches the pin OpenAI's installer carries. It writes the same drop-in bytes OpenAI's installer writes, so a machine that installer already configured is left alone. Then it adds the Include to pacman.conf and refreshes the databases. supplemental_software installs chatgpt-bin next to claude-desktop.
I took the repository over the AUR chatgpt-desktop repackage: the repo keeps the app on ordinary pacman upgrades under OpenAI's signature, and the AUR package conflicts with chatgpt-bin on a machine already using the repo.
An untrusted key imports nothing and leaves pacman.conf alone. With the Include present and the key untrusted, TrustedOnly would fail every later pacman transaction. The step returns non-zero instead and chatgpt-bin is skipped.
Diffstat (limited to 'tests/installer-steps/test_configure_openai_chatgpt_repo.py')
| -rw-r--r-- | tests/installer-steps/test_configure_openai_chatgpt_repo.py | 324 |
1 files changed, 324 insertions, 0 deletions
diff --git a/tests/installer-steps/test_configure_openai_chatgpt_repo.py b/tests/installer-steps/test_configure_openai_chatgpt_repo.py new file mode 100644 index 0000000..cb0e84b --- /dev/null +++ b/tests/installer-steps/test_configure_openai_chatgpt_repo.py @@ -0,0 +1,324 @@ +"""Test configure_openai_chatgpt_repo — OpenAI's signed pacman repo for ChatGPT. + +OpenAI ships the ChatGPT (Codex) desktop app for Arch as chatgpt-bin from +its own signed pacman repository. Its install-arch.sh pins the repository +signing-key fingerprint, locally signs the key into the pacman keyring, +writes /etc/pacman.d/openai-chatgpt.conf and adds an Include line to +pacman.conf; ratio and velox were set up that way on 2026-10-11. The step +reproduces exactly that configuration so a re-run on those machines is a +no-op and the app rides normal pacman upgrades under OpenAI's signature. +The AUR repackage of the same binary (chatgpt-desktop) would conflict with +chatgpt-bin on any machine already using the repo, so it is not used. + +A half-configured repository is worse than none: with the Include in place +and the key untrusted, SigLevel TrustedOnly makes every later pacman call +fail on the invalid database. So an untrusted key (fingerprint mismatch or +failed download) must import nothing and leave pacman.conf untouched. + +The pin is on the primary key's fingerprint alone. pacman-key --lsign-key +resolves a subkey fingerprint to its primary, so a bundle whose subkey or +second primary carries the pinned fingerprint must be rejected too. + +Method: sed-extract configure_openai_chatgpt_repo from the real `archsetup`, +point it at a temp pacman.conf and repo drop-in, and replace curl, gpg, +pacman-key and pacman with recorders driven by environment switches. + + python3 -m unittest tests.installer-steps.test_configure_openai_chatgpt_repo +""" + +import os +import re +import stat +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +FPR = "3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4" +KEY_URL = "https://persistent.oaistatic.com/codex-app-prod/linux/repository-signing-key.gpg" +SERVER = "https://persistent.oaistatic.com/codex-app-prod/linux/arch/$arch" +BASE_CONF = "[core]\nInclude = /etc/pacman.d/mirrorlist\n" + +# Recorders. Every fake appends its argv to $MARKER; the pacman fake also +# records whether the Include line is already in pacman.conf at call time, +# which is how the tests see that -Sy ran after the repo was wired in. +STUBS = r""" +logfile=/dev/null +action="" +display() { :; } +backup_system_file() { :; } +error_warn() { echo "WARN: $1"; return 1; } +pacman-key() { + echo "PACMAN_KEY: $*" >> "$MARKER" + case "$1" in + --list-keys) [ "$KEY_PRESENT" = 1 ] ;; + *) return 0 ;; + esac +} +curl() { + echo "CURL: $*" >> "$MARKER" + [ "$CURL_FAIL" = 1 ] && return 22 + local out="" + while [ $# -gt 0 ]; do + case "$1" in -o) out="$2"; shift ;; esac + shift + done + [ -n "$out" ] && printf 'FAKE KEY\n' > "$out" + return 0 +} +gpg() { + echo "GPG: $*" >> "$MARKER" + case " $* " in + *" --show-keys "*) + printf 'pub:u:4096:1:4A3B4A566C4660E4:1754362069:::u:::scSC::::::23::0:\n' + printf 'fpr:::::::::%s:\n' "$SERVED_FPR" + printf 'uid:u::::1754362069::ABC::Codex Linux Repository::::::::::0:\n' + if [ -n "$SERVED_SUBKEY_FPR" ]; then + printf 'sub:u:4096:1:0123456789ABCDEF:1754362069::::::e::::::23:\n' + printf 'fpr:::::::::%s:\n' "$SERVED_SUBKEY_FPR" + fi + if [ -n "$SERVED_SECOND_PUB_FPR" ]; then + printf 'pub:u:4096:1:FEDCBA9876543210:1754362069:::u:::scSC::::::23::0:\n' + printf 'fpr:::::::::%s:\n' "$SERVED_SECOND_PUB_FPR" + printf 'uid:u::::1754362069::DEF::Somebody Else::::::::::0:\n' + fi + ;; + esac + return 0 +} +pacman() { + local n + n=$(grep -cxF "Include = $REPO_CONF" "$PACMAN_CONF" 2>/dev/null) + echo "PACMAN: $* include=$n" >> "$MARKER" + return 0 +} +""" + + +def paths(d): + return (os.path.join(d, "pacman.conf"), + os.path.join(d, "pacman.d", "openai-chatgpt.conf"), + os.path.join(d, "calls")) + + +def run(d, *, key_present=False, served_fpr=FPR, served_subkey_fpr="", + served_second_pub_fpr="", curl_fail=False, pacman_conf_body=BASE_CONF): + pacman_conf, repo_conf, marker = paths(d) + if pacman_conf_body is not None and not os.path.exists(pacman_conf): + with open(pacman_conf, "w") as f: + f.write(pacman_conf_body) + script = STUBS + textwrap.dedent(f"""\ + source <(sed -n '/^configure_openai_chatgpt_repo() {{/,/^}}/p' "{ARCHSETUP}") + configure_openai_chatgpt_repo "$PACMAN_CONF" "$REPO_CONF" + echo "RC=$?" + exit 0 + """) + env = dict(os.environ, MARKER=marker, PACMAN_CONF=pacman_conf, + REPO_CONF=repo_conf, KEY_PRESENT="1" if key_present else "0", + SERVED_FPR=served_fpr, SERVED_SUBKEY_FPR=served_subkey_fpr, + SERVED_SECOND_PUB_FPR=served_second_pub_fpr, + CURL_FAIL="1" if curl_fail else "0") + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=10, env=env) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +def read(path): + with open(path) as f: + return f.read() + + +def calls(d): + marker = paths(d)[2] + return read(marker) if os.path.exists(marker) else "" + + +class ConfigureOpenaiChatgptRepo(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_writes_the_published_repo_definition(self): + with tempfile.TemporaryDirectory() as d: + r = run(d) + self.assertEqual(rc_of(r), 0) + _, repo_conf, _ = paths(d) + self.assertTrue(os.path.exists(repo_conf)) + body = read(repo_conf) + self.assertIn("[openai-chatgpt]\n", body) + self.assertIn("SigLevel = Required DatabaseRequired TrustedOnly\n", body) + # $arch stays literal: pacman expands it, the shell must not. + self.assertIn(f"Server = {SERVER}\n", body) + + def test_repo_definition_is_world_readable_not_writable(self): + with tempfile.TemporaryDirectory() as d: + run(d) + mode = stat.S_IMODE(os.stat(paths(d)[1]).st_mode) + self.assertEqual(mode, 0o644) + + def test_adds_one_include_line_to_pacman_conf(self): + with tempfile.TemporaryDirectory() as d: + run(d) + pacman_conf, repo_conf, _ = paths(d) + body = read(pacman_conf) + self.assertTrue(body.startswith(BASE_CONF), body) + self.assertEqual(body.count(f"\nInclude = {repo_conf}\n"), 1) + + def test_imports_and_locally_signs_the_pinned_key(self): + with tempfile.TemporaryDirectory() as d: + run(d) + c = calls(d) + self.assertIn(f" {KEY_URL}", c) + self.assertIn("GPG: ", c) + self.assertIn("--show-keys", c) + self.assertIn("PACMAN_KEY: --add ", c) + self.assertIn(f"PACMAN_KEY: --lsign-key {FPR}", c) + + def test_refreshes_databases_after_the_include_is_in_place(self): + with tempfile.TemporaryDirectory() as d: + run(d) + self.assertIn("PACMAN: -Sy include=1", calls(d)) + + # ---------------------------------------------------------- boundary ---- + def test_running_twice_leaves_one_include_and_the_same_files(self): + with tempfile.TemporaryDirectory() as d: + pacman_conf, repo_conf, _ = paths(d) + self.assertEqual(rc_of(run(d)), 0) + first_conf, first_repo = read(pacman_conf), read(repo_conf) + self.assertEqual(rc_of(run(d)), 0) + self.assertEqual(read(pacman_conf), first_conf) + self.assertEqual(read(repo_conf), first_repo) + self.assertEqual(first_conf.count("Include = " + repo_conf), 1) + + def test_already_trusted_key_is_not_downloaded_or_added_again(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, key_present=True) + self.assertEqual(rc_of(r), 0) + c = calls(d) + self.assertNotIn("CURL:", c) + self.assertNotIn("PACMAN_KEY: --add", c) + self.assertTrue(os.path.exists(paths(d)[1])) + + def test_pacman_conf_without_trailing_newline_gets_its_own_line(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, pacman_conf_body=BASE_CONF.rstrip("\n")) + self.assertEqual(rc_of(r), 0) + pacman_conf, repo_conf, _ = paths(d) + body = read(pacman_conf) + self.assertIn("Include = /etc/pacman.d/mirrorlist\n", body) + self.assertRegex(body, re.compile(rf"^Include = {re.escape(repo_conf)}$", re.M)) + + def test_existing_differing_repo_definition_is_kept_and_flagged(self): + with tempfile.TemporaryDirectory() as d: + pacman_conf, repo_conf, _ = paths(d) + os.makedirs(os.path.dirname(repo_conf)) + custom = "[openai-chatgpt]\nSigLevel = Required\nServer = file:///srv/mirror\n" + with open(repo_conf, "w") as f: + f.write(custom) + r = run(d) + self.assertIn("WARN:", r.stdout) + self.assertEqual(read(repo_conf), custom) + # The repository is still configured, just by the user's file. + self.assertIn(f"\nInclude = {repo_conf}\n", read(pacman_conf)) + self.assertEqual(rc_of(r), 0) + + # ------------------------------------------------------------- error ---- + def assert_nothing_configured(self, d, r): + pacman_conf, repo_conf, _ = paths(d) + c = calls(d) + self.assertIn("WARN:", r.stdout) + self.assertNotIn("PACMAN_KEY: --add", c) + self.assertNotIn("PACMAN_KEY: --lsign-key", c) + self.assertNotIn("PACMAN: -Sy", c) + self.assertFalse(os.path.exists(repo_conf)) + self.assertEqual(read(pacman_conf), BASE_CONF) + self.assertNotEqual(rc_of(r), 0) + + def test_fingerprint_mismatch_imports_nothing_and_touches_nothing(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr="DEADBEEF" * 5) + self.assert_nothing_configured(d, r) + + def test_key_download_failure_imports_nothing_and_touches_nothing(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, curl_fail=True) + self.assert_nothing_configured(d, r) + + # The pin is on the PRIMARY key's fingerprint and nothing else. pacman-key + # --lsign-key resolves a subkey fingerprint to the primary that carries it, + # so a pin that a subkey or a second primary could satisfy would trust + # whatever key the bundle smuggled in beside the expected one. + def test_subkey_carrying_the_pinned_fingerprint_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr="DEADBEEF" * 5, served_subkey_fpr=FPR) + self.assert_nothing_configured(d, r) + + def test_second_primary_carrying_the_pinned_fingerprint_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr="DEADBEEF" * 5, served_second_pub_fpr=FPR) + self.assert_nothing_configured(d, r) + + def test_rogue_second_primary_beside_the_pinned_key_is_rejected(self): + with tempfile.TemporaryDirectory() as d: + r = run(d, served_fpr=FPR, served_second_pub_fpr="DEADBEEF" * 5) + self.assert_nothing_configured(d, r) + + @unittest.skipUnless(os.geteuid() != 0, "root ignores file write bits") + def test_unwritable_pacman_conf_warns_and_reports_failure(self): + with tempfile.TemporaryDirectory() as d: + pacman_conf, _, _ = paths(d) + with open(pacman_conf, "w") as f: + f.write(BASE_CONF) + os.chmod(pacman_conf, 0o444) + try: + r = run(d) + self.assertIn("WARN:", r.stdout) + self.assertNotEqual(rc_of(r), 0) + self.assertNotIn("PACMAN: -Sy", calls(d)) + finally: + os.chmod(pacman_conf, 0o644) + + +class SupplementalSoftwareWiring(unittest.TestCase): + """chatgpt-bin is installed only once its repository is trusted.""" + + def run_step(self, repo_rc): + script = textwrap.dedent(f"""\ + desktop_env=hyprland + display() {{ :; }} + run_task() {{ :; }} + mask_fwupd_passim() {{ :; }} + pacman_install() {{ echo "PACMAN_INSTALL: $1"; }} + aur_install() {{ echo "AUR_INSTALL: $1"; }} + configure_openai_chatgpt_repo() {{ echo "CONFIGURE_REPO"; return {repo_rc}; }} + source <(sed -n '/^supplemental_software() {{/,/^}}/p' "{ARCHSETUP}") + supplemental_software + """) + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=10) + + def test_installs_chatgpt_bin_after_configuring_the_repo(self): + out = self.run_step(0).stdout + self.assertIn("CONFIGURE_REPO\n", out) + self.assertLess(out.index("CONFIGURE_REPO"), + out.index("PACMAN_INSTALL: chatgpt-bin")) + + def test_skips_chatgpt_bin_when_the_repo_cannot_be_trusted(self): + out = self.run_step(1).stdout + self.assertNotIn("PACMAN_INSTALL: chatgpt-bin", out) + # The failure is local to the app; the rest of the step still runs. + self.assertIn("AUR_INSTALL: claude-desktop", out) + self.assertIn("AUR_INSTALL: freetube", out) + + def test_does_not_also_pull_the_conflicting_aur_repackage(self): + out = self.run_step(0).stdout + self.assertNotIn("chatgpt-desktop", out) + + +if __name__ == "__main__": + unittest.main() |
