aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-10-12 01:19:10 -0500
committerCraig Jennings <c@cjennings.net>2026-10-12 01:19:10 -0500
commite530fd597b62bda520f9804be7da216eea582750 (patch)
tree3d356e371120bd3f64d2af01daecb88a219abdcb
parenta6a66ebcff40ea95bbda5aa8f890850ca87e822b (diff)
downloadarchsetup-e530fd597b62bda520f9804be7da216eea582750.tar.gz
archsetup-e530fd597b62bda520f9804be7da216eea582750.zip
feat: install the ChatGPT desktop app from OpenAI's pacman repoHEADmain
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.
-rwxr-xr-xarchsetup89
-rw-r--r--tests/installer-steps/test_configure_openai_chatgpt_repo.py324
2 files changed, 413 insertions, 0 deletions
diff --git a/archsetup b/archsetup
index 1c6fb01..26157b3 100755
--- a/archsetup
+++ b/archsetup
@@ -3354,6 +3354,88 @@ mask_fwupd_passim() {
run_task "masking passim (fwupd's LAN metadata sharing daemon)" systemctl mask passim.service
}
+# ChatGPT desktop (the Codex app) ships for Arch as chatgpt-bin from OpenAI's
+# own signed pacman repository. I configure that repository rather than take
+# the AUR repackage of the same binary (chatgpt-desktop): the repo keeps the
+# app on ordinary pacman upgrades under OpenAI's signature, it is how ratio
+# and velox were set up on 2026-10-11 (OpenAI's install-arch.sh), and the
+# AUR package conflicts with chatgpt-bin on a machine already using the repo.
+# The drop-in is byte-identical to the one OpenAI's installer writes, so a
+# machine it configured compares equal here and is left alone.
+#
+# Trust is pinned, not delegated: the key is fetched from the publisher and
+# accepted only when its primary fingerprint matches the one carried here
+# (the same pin OpenAI's installer ships). An untrusted key imports nothing
+# and leaves pacman.conf untouched, because a half-configured repository is
+# worse than none: with the Include present and the key untrusted, SigLevel
+# TrustedOnly makes every later pacman transaction fail on the invalid
+# database. Returns non-zero only when the repository is not usable, so the
+# caller can skip chatgpt-bin instead of watching it fail.
+#
+# $1 pacman.conf path, $2 repository drop-in path (overridable for tests).
+configure_openai_chatgpt_repo() {
+ local pacman_conf="${1:-/etc/pacman.conf}"
+ local repo_conf="${2:-/etc/pacman.d/openai-chatgpt.conf}"
+ local key_url="https://persistent.oaistatic.com/codex-app-prod/linux/repository-signing-key.gpg"
+ local key_fpr="3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4"
+ local include_line="Include = $repo_conf"
+ local tmp fpr
+
+ if ! pacman-key --list-keys "$key_fpr" >> "$logfile" 2>&1; then
+ action="importing the OpenAI repository signing key" && display "task" "$action"
+ tmp=$(mktemp -d) || { error_warn "$action" "$?"; return 1; }
+ curl --proto '=https' --tlsv1.2 --connect-timeout 15 -fsSL \
+ -o "$tmp/key.gpg" "$key_url" >> "$logfile" 2>&1 || \
+ { error_warn "$action (download failed)" "$?"; rm -rf "$tmp"; return 1; }
+ # Primary-key fingerprint only; a subkey line must not satisfy the pin.
+ fpr=$(gpg --homedir "$tmp" --batch --show-keys --with-colons "$tmp/key.gpg" 2>> "$logfile" |
+ awk -F: '$1 == "pub" { primary=1 } $1 == "sub" { primary=0 } $1 == "fpr" && primary { print $10; primary=0 }')
+ if [ "$fpr" != "$key_fpr" ]; then
+ error_warn "$action (fingerprint ${fpr:-unreadable}, expected $key_fpr)" 1
+ rm -rf "$tmp"
+ return 1
+ fi
+ pacman-key --add "$tmp/key.gpg" >> "$logfile" 2>&1 || \
+ { error_warn "$action" "$?"; rm -rf "$tmp"; return 1; }
+ rm -rf "$tmp"
+ fi
+ # The local signature is what TrustedOnly accepts. Signing is idempotent,
+ # so an already-signed key costs nothing, and a run that added the key but
+ # died before signing gets repaired here.
+ action="trusting the OpenAI repository signing key" && display "task" "$action"
+ pacman-key --lsign-key "$key_fpr" >> "$logfile" 2>&1 || { error_warn "$action" "$?"; return 1; }
+
+ action="configuring the openai-chatgpt repository" && display "task" "$action"
+ tmp=$(mktemp) || { error_warn "$action" "$?"; return 1; }
+ cat << 'EOF' > "$tmp"
+### THIS FILE IS AUTOMATICALLY CONFIGURED ###
+# Remove it and its Include line to opt out of automatic package updates.
+[openai-chatgpt]
+SigLevel = Required DatabaseRequired TrustedOnly
+Server = https://persistent.oaistatic.com/codex-app-prod/linux/arch/$arch
+EOF
+ if [ -e "$repo_conf" ] && ! cmp -s "$repo_conf" "$tmp"; then
+ error_warn "$action (keeping the existing $repo_conf, which differs from the published definition)" 1
+ else
+ install -D -m 0644 "$tmp" "$repo_conf" 2>> "$logfile" || \
+ { error_warn "$action" "$?"; rm -f "$tmp"; return 1; }
+ fi
+ rm -f "$tmp"
+
+ if ! grep -qxF "$include_line" "$pacman_conf" 2>/dev/null; then
+ action="including the openai-chatgpt repository in $pacman_conf" && display "task" "$action"
+ backup_system_file "$pacman_conf"
+ # Start on a line of its own even when the file lacks a final newline.
+ local sep=""
+ [ -n "$(tail -c1 "$pacman_conf" 2>/dev/null)" ] && sep=$'\n'
+ printf '%s\n%s\n' "$sep" "$include_line" >> "$pacman_conf" 2>> "$logfile" || \
+ { error_warn "$action" "$?"; return 1; }
+ fi
+
+ action="refreshing package databases (openai-chatgpt)" && display "task" "$action"
+ pacman -Sy >> "$logfile" 2>&1 || { error_warn "$action" "$?"; return 1; }
+}
+
supplemental_software() {
display "title" "Supplemental Software"
@@ -3419,6 +3501,13 @@ supplemental_software() {
# Claude desktop app (repackaged official .deb). Hard-depends on
# qemu-system-x86, edk2-ovmf and virtiofsd for its Cowork VM.
aur_install claude-desktop
+ # ChatGPT desktop (the Codex app) from OpenAI's signed repository; see
+ # configure_openai_chatgpt_repo for why not the AUR chatgpt-desktop
+ # repackage. Skipped (warning already recorded) when the repository
+ # could not be trusted, so the rest of the step is unaffected.
+ if configure_openai_chatgpt_repo; then
+ pacman_install chatgpt-bin # ChatGPT / Codex desktop app
+ fi
# zoom retired 2026-07-02: zoom-web (dotfiles) opens meetings in the browser
pacman_install iperf3 # network bandwidth testing
pacman_install bind # DNS utilities (dig, host, nslookup)
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()