36 changed files with 2663 additions and 931 deletions
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/panama
@@ -9,7 +9,7 @@ You are editing a running desktop, not a codebase that gets deployed later. Ever
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
drives every rule below. drives every rule below.
Before large work, read these in order: Read before large work, in this order:
- `README.md` — layout, the `panama` command, how installing and updating work - `README.md` — layout, the `panama` command, how installing and updating work
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one - `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
@@ -62,8 +62,7 @@ Every executable contract under `tests/` is classified in `tests/contracts.manif
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr. `PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
Successful stdout stays quiet. Successful stderr is surfaced as a warning. Successful stdout stays quiet. Successful stderr is surfaced as a warning.
- Contracts run directly too: `tests/setup/interview-contract`. - Contracts run directly too: `tests/setup/interview-contract`.
- After changing `PreferenceSchema.qml` or - After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
`config/dot/quickshell/services/SettingsRoutes.qml`, regenerate:
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and `config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links). `config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale. Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
+4 -7
View File
@@ -102,7 +102,7 @@ in order, without stopping again:
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path | | `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen | | `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
| `link-dotfiles` | Symlinks `config/dot/<name>``~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome | | `link-dotfiles` | Symlinks `config/dot/<name>``~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
| `link-skills` | Links the agent skills in `skills/` into `~/.agents/skills` and `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash | | `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) | | `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
| `link-vicinae-scripts` | Publishes the Vicinae script commands | | `link-vicinae-scripts` | Publishes the Vicinae script commands |
@@ -235,13 +235,10 @@ server/ The server role: compose services (one directory per
service), the nightly image updater, and its units. See service), the nightly image updater, and its units. See
server/README.md server/README.md
skills/ Agent skills for operating this desktop, linked into skills/ Agent skills for operating this desktop, linked into
~/.agents/skills and ~/.claude/skills ~/.claude/skills
setup/ setup/
apps/ Applications built from source, one file each apps/ Applications built from source, one file each
keys/ Pinned signing keys, for publishers that ship no fetchable lib/ Shared by more than one stage; the extras catalog reader
one; setup/keys/README.md records where each came from
lib/ Shared by more than one stage; the extras catalog reader,
the machine role, the verified ChatGPT repository
packages/ One package per line; extras/ holds the optional categories packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install scripts/ Run in order by ./install
tests/ Contracts. See below tests/ Contracts. See below
@@ -250,7 +247,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
189 of them, under `tests/`. `tests/contracts.manifest` classifies every 188 of them, under `tests/`. `tests/contracts.manifest` classifies every
contract by the capabilities it needs. Run the hermetic set, or grant a contract by the capabilities it needs. Run the hermetic set, or grant a
specific external capability when automation needs it: specific external capability when automation needs it:
+2 -8
View File
@@ -134,6 +134,7 @@ ${BOLD}Examples:${RESET}
$PROGRAM upgrade $PROGRAM upgrade
$PROGRAM apps $PROGRAM apps
$PROGRAM app $PROGRAM app
$PROGRAM app chatgpt-desktop
EOF EOF
} }
@@ -450,16 +451,11 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
contract_paths() { contract_paths() {
local candidate local candidate
# The manifest is kept in byte order, so both the discovery sort and the
# comparison below have to be byte order too. A UTF-8 collation folds the
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
# a gate that passes or fails on the machine's LANG is not a gate.
while IFS= read -r candidate; do while IFS= read -r candidate; do
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue [[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}" printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
done < <(find "$PANAMA_DIR/tests" -type f \ done < <(find "$PANAMA_DIR/tests" -type f \
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort) -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
} }
contract_manifest_entries() { contract_manifest_entries() {
@@ -482,8 +478,6 @@ validate_contract_manifest() {
require_contract_manifest || return 1 require_contract_manifest || return 1
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST" local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
# Byte order, for the same reason contract_paths sorts in it.
local LC_ALL=C
local line capabilities path extra previous_comment="" previous_was_comment=0 local line capabilities path extra previous_comment="" previous_was_comment=0
local previous_path="" capability discovered local previous_path="" capability discovered
local -a capability_list=() findings=() local -a capability_list=() findings=()
+89 -6
View File
@@ -45,6 +45,65 @@ checkout_command() {
fi fi
} }
# Git's index hints are performance promises, not trust evidence. In
# particular, assume-unchanged and skip-worktree can make porcelain status
# report a clean checkout whose files no longer match HEAD. Compare every
# tracked blob and Git mode with the verified commit before handing control to
# any file in the worktree.
checkout_matches_verified_commit() (
local checkout="$1" listing="" entry metadata mode type expected path actual
local link_target_with_sentinel link_target
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
umask 077
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
listing=""
exit 1
fi
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
while IFS= read -r -d '' entry; do
[[ "$entry" == *$'\t'* ]] || exit 1
metadata="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
read -r mode type expected <<<"$metadata"
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
case "$mode" in
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& ! -x "$checkout/$path" ]] || exit 1 ;;
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& -x "$checkout/$path" ]] || exit 1 ;;
120000)
[[ -L "$checkout/$path" ]] || exit 1
# hash-object given a pathname follows a symlink. Git's 120000 blob is
# the link text itself, including any trailing newlines, so preserve
# those bytes with a sentinel and hash stdin instead.
link_target_with_sentinel="$(
readlink -n -- "$checkout/$path" && printf .
)" || exit 1
[[ "$link_target_with_sentinel" == *. ]] || exit 1
link_target="${link_target_with_sentinel%.}"
actual="$(
printf '%s' "$link_target" \
| checkout_command git -C "$checkout" hash-object --stdin
)" || exit 1
[[ "$actual" == "$expected" ]] || exit 1
continue
;;
*) exit 1 ;;
esac
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|| exit 1
[[ "$actual" == "$expected" ]] || exit 1
done <"$listing"
)
prepare_panama_checkout() { prepare_panama_checkout() {
local checkout="$1" actual_head checkout_status local checkout="$1" actual_head checkout_status
@@ -110,6 +169,30 @@ for arg in "$@"; do
esac esac
done done
# Keep the worktree comparison at the last possible boundary. Checkout
# preparation may invoke several commands and return to the caller; performing
# the byte/mode/link check here ensures a change in that interval is rejected
# before any tracked file is executed.
verified_install_handoff() {
local use_tty="$1"
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
return 1
fi
if [[ -n "$BOOTSTRAP_USER" ]]; then
if (( use_tty )); then
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
fi
if (( use_tty )); then
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
}
# The public bootstrap contract runs this branch as an ordinary user with a # The public bootstrap contract runs this branch as an ordinary user with a
# stubbed root identity. Keep its filesystem adapter unavailable to a real root # stubbed root identity. Keep its filesystem adapter unavailable to a real root
# shell so it cannot redirect a real installation by accident. # shell so it cannot redirect a real installation by accident.
@@ -471,7 +554,7 @@ if [[ "$(id -u)" -eq 0 ]]; then
if ! command -v git >/dev/null 2>&1; then if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs" echo "Installing git, which the clone needs"
dnf install -y git dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi fi
# Create or advance the checkout as the target user. A root-owned .git in a # Create or advance the checkout as the target user. A root-owned .git in a
@@ -481,15 +564,14 @@ if [[ "$(id -u)" -eq 0 ]]; then
prepare_panama_checkout "$PANAMA_PATH" prepare_panama_checkout "$PANAMA_PATH"
echo "Handing off to install as $username" echo "Handing off to install as $username"
exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \ verified_install_handoff 1
"$PANAMA_PATH/install" --server </dev/tty
fi fi
# git is the one dependency the clone itself needs. Everything else -- gum # git is the one dependency the clone itself needs. Everything else -- gum
# included -- is bootstrapped by `install`. # included -- is bootstrapped by `install`.
if ! command -v git >/dev/null 2>&1; then if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs" echo "Installing git, which the clone needs"
sudo dnf install -y git sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi fi
prepare_panama_checkout "$PANAMA_PATH" prepare_panama_checkout "$PANAMA_PATH"
@@ -500,7 +582,8 @@ prepare_panama_checkout "$PANAMA_PATH"
# so itself. # so itself.
# The probe actually opens /dev/tty rather than testing -r: a process with no # The probe actually opens /dev/tty rather than testing -r: a process with no
# controlling terminal passes -r and then fails the redirect. # controlling terminal passes -r and then fails the redirect.
handoff_tty=0
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty handoff_tty=1
fi fi
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} verified_install_handoff "$handoff_tty"
-1
View File
@@ -22,7 +22,6 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
alias nlc='nvim leetcode.nvim' alias nlc='nvim leetcode.nvim'
alias clauded='claude --allow-dangerously-skip-permissions' alias clauded='claude --allow-dangerously-skip-permissions'
alias claudedr='claude --allow-dangerously-skip-permissions --resume' alias claudedr='claude --allow-dangerously-skip-permissions --resume'
alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude'
alias commit-repo='git add -A && git commit -m "Update stuff" && git push' alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
# Docker Shortcuts # Docker Shortcuts
@@ -1805,7 +1805,7 @@ Singleton {
"org.mozilla.thunderbird_esr", "com.slack.Slack", "org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk", "app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude", "io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
"chatgpt", "md.obsidian.Obsidian", "codex-desktop", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam" "com.obsproject.Studio", "steam"
] ]
}, },
+88 -34
View File
@@ -88,40 +88,78 @@ source "$PANAMA_PATH/bin/ascii"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
PACKAGES_HASH="$STATE_DIR/packages-hash" PACKAGES_HASH="$STATE_DIR/packages-hash"
hash_packages() { _collect_package_inputs() {
local file relative size fixed_input digest local destination="$1" raw="${1}.raw"
[[ -d "$PANAMA_PATH/setup/packages" \
for fixed_input in \ && ! -L "$PANAMA_PATH/setup/packages" \
"$PANAMA_PATH/setup/scripts/install-packages" \ && -d "$PANAMA_PATH/setup/provenance" \
"$PANAMA_PATH/setup/lib/artifact-provenance"; do && ! -L "$PANAMA_PATH/setup/provenance" ]] || return 1
[[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1
done
digest="$(
{ {
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1
printf '%s\0' \ printf '%s\0' \
"$PANAMA_PATH/setup/scripts/install-packages" \ "$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance" || exit 1 "$PANAMA_PATH/setup/lib/artifact-provenance" \
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1 "$PANAMA_PATH/setup/lib/extras-catalog" \
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do "$PANAMA_PATH/setup/lib/machine-role" || exit 1
relative="${file#"$PANAMA_PATH"/}" # extras/ is deliberately excluded. A symlink or other non-directory
size="$(wc -c <"$file")" || exit 1 # object at this level is still an input error, not something discovery may
printf '%s\0%s\0' "$relative" "$size" || exit 1 # silently omit.
cat -- "$file" || exit 1 find "$PANAMA_PATH/setup/packages" -mindepth 1 -maxdepth 1 \
printf '\0' || exit 1 ! -type d -print0 || exit 1
done | sha256sum | cut -d' ' -f1 find "$PANAMA_PATH/setup/provenance" -mindepth 1 \
)" || return 1 ! -type d -print0 || exit 1
printf '%s\n' "$digest" } >"$raw" || return 1
LC_ALL=C sort -z "$raw" >"$destination"
} }
_write_package_manifest() {
local inputs="$1" destination="$2" file relative digest
: >"$destination" || return 1
while IFS= read -r -d '' file; do
[[ -f "$file" && ! -L "$file" && -r "$file" ]] || return 1
relative="${file#"$PANAMA_PATH"/}"
[[ "$relative" != "$file" ]] || return 1
digest="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1
printf '%s\0%s\0' "$relative" "$digest" >>"$destination" || return 1
done <"$inputs"
}
# Read every input twice from the same enumerated set. A file or path that
# changes while the snapshot is built cannot produce a receipt.
hash_packages() (
local work="" inputs_before inputs_after manifest_before manifest_after
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
work="$(mktemp -u -d -t panama-packages-hash.XXXXXX)" || exit 1
if ! mkdir -m 700 -- "$work"; then
work=""
exit 1
fi
inputs_before="$work/inputs-before"
inputs_after="$work/inputs-after"
manifest_before="$work/manifest-before"
manifest_after="$work/manifest-after"
_collect_package_inputs "$inputs_before" || exit 1
_write_package_manifest "$inputs_before" "$manifest_before" || exit 1
_collect_package_inputs "$inputs_after" || exit 1
cmp -s -- "$inputs_before" "$inputs_after" || exit 1
_write_package_manifest "$inputs_after" "$manifest_after" || exit 1
cmp -s -- "$manifest_before" "$manifest_after" || exit 1
sha256sum -- "$manifest_before" | awk '{ print $1 }'
)
PACKAGE_START_HASH=""
packages_needed() { packages_needed() {
local current_hash recorded_hash local current_hash recorded_hash
current_hash="$(hash_packages)" || return 2
PACKAGE_START_HASH="$current_hash"
(( FORCE_PACKAGES )) && return 0 (( FORCE_PACKAGES )) && return 0
(( UPGRADE )) || return 0 (( UPGRADE )) || return 0
[[ -r "$PACKAGES_HASH" ]] || return 0 [[ -r "$PACKAGES_HASH" ]] || return 0
current_hash="$(hash_packages)" || return 2
recorded_hash="$(cat "$PACKAGES_HASH")" || return 2 recorded_hash="$(cat "$PACKAGES_HASH")" || return 2
[[ "$current_hash" != "$recorded_hash" ]] [[ "$current_hash" != "$recorded_hash" ]]
} }
@@ -129,17 +167,25 @@ packages_needed() {
# Written only after the stage succeeds, mirroring the rule panama-migrate # Written only after the stage succeeds, mirroring the rule panama-migrate
# documents for its markers: a step that did not complete has not happened, and # documents for its markers: a step that did not complete has not happened, and
# recording it as done hides it forever. # recording it as done hides it forever.
record_packages_hash() { record_packages_hash() (
local temporary_hash local expected_hash="$1" current_hash temporary_hash=""
trap '[[ -z "$temporary_hash" ]] || rm -f -- "$temporary_hash"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
[[ "$expected_hash" =~ ^[0-9a-f]{64}$ ]] || return 1
current_hash="$(hash_packages)" || return 1
[[ "$current_hash" == "$expected_hash" ]] || return 1
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1 temporary_hash="$(mktemp -u "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
if hash_packages >"$temporary_hash"; then umask 077
mv -f -- "$temporary_hash" "$PACKAGES_HASH" if ! (set -o noclobber; : >"$temporary_hash") 2>/dev/null; then
else temporary_hash=""
rm -f -- "$temporary_hash"
return 1 return 1
fi fi
} printf '%s\n' "$expected_hash" >"$temporary_hash" || return 1
mv -f -- "$temporary_hash" "$PACKAGES_HASH" || return 1
temporary_hash=""
)
# Repository trust is checked before the installer can reach its bootstrap DNF. # Repository trust is checked before the installer can reach its bootstrap DNF.
# Status 78 is reserved for a trust-root failure and is propagated unchanged so # Status 78 is reserved for a trust-root failure and is propagated unchanged so
@@ -190,7 +236,8 @@ if (( ! UPGRADE )); then
fi fi
if (( ${#bootstrap[@]} > 0 )); then if (( ${#bootstrap[@]} > 0 )); then
echo "Installing what the setup questions are built on: ${bootstrap[*]}" echo "Installing what the setup questions are built on: ${bootstrap[*]}"
sudo dnf install -y "${bootstrap[@]}" >/dev/null || { sudo dnf install -y --repo=fedora --repo=updates \
--from-repo=fedora,updates "${bootstrap[@]}" >/dev/null || {
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2 echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
exit 1 exit 1
} }
@@ -253,7 +300,12 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# is unset and each stage takes the empty-answer path it already documents -- # is unset and each stage takes the empty-answer path it already documents --
# which is why this is a flag rather than a rewrite of seven stage scripts. # which is why this is a flag rather than a rewrite of seven stage scripts.
if (( ! UPGRADE )); then if (( ! UPGRADE )); then
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" PANAMA_ANSWERS="$(mktemp -u -t panama-answers.XXXXXX)" || exit 1
umask 077
if ! (set -o noclobber; : >"$PANAMA_ANSWERS") 2>/dev/null; then
PANAMA_ANSWERS=""
exit 1
fi
export PANAMA_ANSWERS export PANAMA_ANSWERS
if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
@@ -343,8 +395,10 @@ for stage in "${STAGES[@]}"; do
[[ -x "$script" ]] || continue [[ -x "$script" ]] || continue
printf '\n=== %s ===\n' "$stage" printf '\n=== %s ===\n' "$stage"
if [[ "$stage" == install-packages ]]; then if [[ "$stage" == install-packages ]]; then
package_start_hash=""
package_state_status=0 package_state_status=0
packages_needed || package_state_status=$? packages_needed || package_state_status=$?
package_start_hash="$PACKAGE_START_HASH"
if (( package_state_status == 1 )); then if (( package_state_status == 1 )); then
echo "The package lists have not changed since the last run; skipping." echo "The package lists have not changed since the last run; skipping."
echo "Run with --packages to install them anyway." echo "Run with --packages to install them anyway."
@@ -357,7 +411,7 @@ for stage in "${STAGES[@]}"; do
fi fi
if "$script"; then if "$script"; then
if [[ "$stage" == install-packages ]]; then if [[ "$stage" == install-packages ]]; then
if ! record_packages_hash; then if ! record_packages_hash "$package_start_hash"; then
failed+=("$stage") failed+=("$stage")
printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2 printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2
fi fi
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
# replace the community ChatGPT Desktop build with the official OpenAI package
#
# `panama app chatgpt-desktop` used to build a community wrapper (codex-desktop)
# from the upstream macOS disk image, complete with a local rebuild daemon.
# OpenAI ships an official Linux RPM now, and the installer takes that instead;
# this repairs machines still carrying the community build. The official
# package goes on before the community one comes off, so a failure part-way
# leaves the machine with an app, never without one.
#
# Rules, because the runner cannot enforce them:
#
# * Safe to run twice. The marker records success, not intent.
# * Tolerant of the repair already being correct -- the user may have fixed
# it by hand, or a later ./install may have put it back.
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
# so the password prompt names the repair.
# * Exit non-zero to be retried at the next login. Exit zero only when the
# machine is genuinely in the state this describes.
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# Machines that never had the community build are already correct. The official
# app is the installer's job, not this one's.
rpm -q codex-desktop >/dev/null 2>&1 || exit 0
# The same verified repository the installer establishes: the pinned signing
# key, then a repository that names it, so dnf checks OpenAI's signature before
# root installs anything. See setup/lib/chatgpt-package.
# shellcheck source=../setup/lib/chatgpt-package
source "$PANAMA_PATH/setup/lib/chatgpt-package"
sudo_cmd=(sudo)
if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then
sudo_cmd=(
"$PANAMA_PATH/bin/panama-sudo" --reason
"Replacing the community-built ChatGPT Desktop (codex-desktop) with the official OpenAI package"
--
)
fi
# The official package first, so the machine is never left without one.
if ! rpm -q chatgpt >/dev/null 2>&1; then
chatgpt_install_repository "${sudo_cmd[@]}"
"${sudo_cmd[@]}" dnf install -y chatgpt
fi
# The community package's updater is a user unit; stop it before dnf removes
# the unit file out from under it. Removal also takes the app in /opt, both
# binaries, and the polkit policy the local rebuilds needed.
systemctl --user disable --now codex-update-manager.service 2>/dev/null || true
"${sudo_cmd[@]}" dnf remove -y codex-desktop
systemctl --user daemon-reload 2>/dev/null || true
# The rebuild state the updater kept; the official package needs none of it.
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/codex-update-manager" \
"${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes" \
"${XDG_CONFIG_HOME:-$HOME/.config}/codex-update-manager" \
"${XDG_STATE_HOME:-$HOME/.local/state}/codex-update-manager"
echo "Replaced the community codex-desktop build with the official chatgpt package."
+19
View File
@@ -0,0 +1,19 @@
# ChatGPT Desktop.
#
# OpenAI ships macOS and Windows only. This is a community wrapper that converts
# the upstream macOS disk image into a Linux Electron app and packages it as an
# RPM, so the installed result is again something dnf owns.
#
# Same exception, same reason: there is no packaged form to prefer. Nothing is
# pinned; `bootstrap-native` fetches the current upstream image each time and
# fails loudly when it cannot.
description="ChatGPT Desktop, built into a Fedora RPM"
repo="https://github.com/ilysenko/codex-desktop-linux.git"
# bootstrap-native installs build dependencies, builds, packages, and installs
# the newest artifact -- so unlike the Claude build there is no separate install
# step to do here.
build() {
make bootstrap-native
}
-38
View File
@@ -1,38 +0,0 @@
# Pinned signing keys
A key lands here when a publisher signs what Panama installs but does not
publish the key, or its fingerprint, anywhere an install could fetch and check
them first. Pinning the key is what lets `dnf` verify a download before root
ever sees it.
Nothing here is a secret. These are public keys, and the reason to track them
is that a *changed* one should be a merge request somebody reads, not a silent
change of who is trusted.
## `RPM-GPG-KEY-chatgpt`
| | |
| --- | --- |
| Fingerprint | `3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4` |
| User ID | `Codex Linux Repository` |
| Signs | the `chatgpt` package and the repository metadata at `https://persistent.oaistatic.com/codex-app-prod/linux/rpm/$basearch` |
| Used by | `setup/lib/chatgpt-package` |
Captured on 2026-08-27 from a machine where the official package had been
installed, at `/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt`, where the package's own
root scriptlet writes it. It is the key that signed both the installed
`chatgpt` package and the live `repodata/repomd.xml.asc`.
Be honest about what that is worth: OpenAI's documented instructions
(<https://learn.chatgpt.com/docs/linux/linux-app>) are to download an RPM and
install it, and they publish no key URL and no fingerprint to compare against.
So this is trust established on first use and then held, not trust verified
against the publisher. Held is the part that matters -- from here every machine
checks the same fingerprint, and a swapped download fails instead of installing.
To re-derive the fingerprint from the file:
```bash
gpg --show-keys --with-colons setup/keys/RPM-GPG-KEY-chatgpt \
| awk -F: '$1 == "fpr" { print $10; exit }'
```
-28
View File
@@ -1,28 +0,0 @@
-----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-----
+47 -29
View File
@@ -7,26 +7,36 @@
declare -gA INSTALLER_PROVENANCE=() declare -gA INSTALLER_PROVENANCE=()
_primary_key_fingerprints() ( _primary_key_fingerprints() (
local home local home="" gpg_output
home="$(mktemp -d)" || exit 1 trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT
chmod 700 "$home"
trap 'rm -rf -- "$home"' EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
GNUPGHOME="$home" gpg --batch --with-colons --import-options show-only --import "$1" 2>/dev/null \ home="$(mktemp -u -d -t panama-gpg.XXXXXX)" || exit 1
| awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }' if ! mkdir -m 700 -- "$home"; then
home=""
exit 1
fi
gpg_output="$(GNUPGHOME="$home" gpg --batch --with-colons \
--import-options show-only --import "$1" 2>/dev/null)" || exit 1
awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }' \
<<<"$gpg_output"
) )
key_fingerprint_matches() { key_fingerprint_matches() {
local file="$1" expected="$2" local file="$1" expected="$2" output
local -a primary_fingerprints=() local -a primary_fingerprints=()
mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$file") output="$(_primary_key_fingerprints "$file")" || return 1
[[ -n "$output" ]] || return 1
mapfile -t primary_fingerprints <<<"$output"
[[ ${#primary_fingerprints[@]} -eq 1 && "${primary_fingerprints[0]}" == "$expected" ]] [[ ${#primary_fingerprints[@]} -eq 1 && "${primary_fingerprints[0]}" == "$expected" ]]
} }
_key_has_one_primary() { _key_has_one_primary() {
local output
local -a primary_fingerprints=() local -a primary_fingerprints=()
mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$1") output="$(_primary_key_fingerprints "$1")" || return 1
[[ -n "$output" ]] || return 1
mapfile -t primary_fingerprints <<<"$output"
[[ ${#primary_fingerprints[@]} -eq 1 ]] [[ ${#primary_fingerprints[@]} -eq 1 ]]
} }
@@ -34,11 +44,15 @@ verify_detached_signature() {
local key="$1" signature="$2" content="$3" home local key="$1" signature="$2" content="$3" home
_key_has_one_primary "$key" || return 1 _key_has_one_primary "$key" || return 1
( (
home="$(mktemp -d)" || exit 1 home=""
chmod 700 "$home" trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT
trap 'rm -rf -- "$home"' EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
home="$(mktemp -u -d -t panama-gpg.XXXXXX)" || exit 1
if ! mkdir -m 700 -- "$home"; then
home=""
exit 1
fi
GNUPGHOME="$home" gpg --batch --quiet --import "$key" >/dev/null 2>&1 \ GNUPGHOME="$home" gpg --batch --quiet --import "$key" >/dev/null 2>&1 \
&& GNUPGHOME="$home" gpg --batch --verify "$signature" "$content" >/dev/null 2>&1 && GNUPGHOME="$home" gpg --batch --verify "$signature" "$content" >/dev/null 2>&1
) )
@@ -60,7 +74,11 @@ download_sha256() {
[[ "$max_bytes" =~ ^[1-9][0-9]*$ ]] || exit 1 [[ "$max_bytes" =~ ^[1-9][0-9]*$ ]] || exit 1
[[ -n "$destination" && -d "$directory" ]] || exit 1 [[ -n "$destination" && -d "$directory" ]] || exit 1
umask 077 umask 077
part="$(mktemp "$directory/.${filename}.part.XXXXXX")" || exit 1 part="$(mktemp -u "$directory/.${filename}.part.XXXXXX")" || exit 1
if ! (set -o noclobber; : >"$part") 2>/dev/null; then
part=""
exit 1
fi
curl --fail --location --connect-timeout 10 --max-time 600 \ curl --fail --location --connect-timeout 10 --max-time 600 \
--max-filesize "$max_bytes" --output "$part" "$url" \ --max-filesize "$max_bytes" --output "$part" "$url" \
|| exit 1 || exit 1
@@ -71,25 +89,25 @@ download_sha256() {
) )
} }
rpm_signature_matches() { rpm_signature_matches() (
local package="$1" key="$2" expected="$3" home db output status local package="$1" key="$2" expected="$3" home="" db output
trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
key_fingerprint_matches "$key" "$expected" || return 1 key_fingerprint_matches "$key" "$expected" || exit 1
home="$(mktemp -d)" || return 1 home="$(mktemp -u -d -t panama-rpm-signature.XXXXXX)" || exit 1
chmod 700 "$home" if ! mkdir -m 700 -- "$home"; then
home=""
exit 1
fi
db="$home/rpmdb" db="$home/rpmdb"
mkdir -m 700 "$db" || { mkdir -m 700 "$db" || exit 1
rm -rf -- "$home" rpmkeys --dbpath "$db" --import "$key" >/dev/null 2>&1 || exit 1
return 1 output="$(rpmkeys --dbpath "$db" --checksig --verbose "$package" 2>&1)" \
} || exit 1
rpmkeys --dbpath "$db" --import "$key" >/dev/null 2>&1 \
&& output="$(rpmkeys --dbpath "$db" --checksig --verbose "$package" 2>&1)"
status=$?
rm -rf -- "$home"
(( status == 0 )) || return 1
grep -Eqi 'OpenPGP.*signature.*: OK' <<<"$output" grep -Eqi 'OpenPGP.*signature.*: OK' <<<"$output"
} )
load_installer_provenance() { load_installer_provenance() {
local file="$1" line name value required local file="$1" line name value required
-97
View File
@@ -1,97 +0,0 @@
# Installing OpenAI's ChatGPT Desktop without trusting the download. Sourced,
# not run.
#
# OpenAI signs both its packages and its repository metadata, with one key, and
# publishes neither that key nor its fingerprint anywhere a first install could
# fetch them. The documented instructions are "download this RPM and install
# it" -- and the RPM's own root scriptlet is what writes the repository file and
# drops the key into /etc/pki/rpm-gpg. Following them means handing an
# unverified download to root and letting it decide afterwards what to trust,
# which is the one thing this repository will not do with a network response.
#
# So the key is pinned here instead. setup/keys/ carries a copy and records
# where it came from; this verifies that copy's fingerprint, installs it, and
# writes the repository itself with gpgcheck on. dnf then checks the metadata
# signature and the package signature against that key before anything runs as
# root, and every later upgrade goes through the same repository and the same
# key.
#
# Two callers, which is why this is a library: install-packages, for a machine
# being built, and the migration that replaces the community codex-desktop
# build on machines that predate the official package.
# The key that signs the packages and the repository metadata. Pinned, so a
# substituted key is a failure here rather than a silent change of publisher.
CHATGPT_KEY_FINGERPRINT="3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4"
# `$basearch` stays literal: dnf expands it, and this is the same base URL the
# package's own scriptlet configures.
CHATGPT_REPO_BASEURL="https://persistent.oaistatic.com/codex-app-prod/linux/rpm/\$basearch"
CHATGPT_REPO_FILE="/etc/yum.repos.d/chatgpt.repo"
CHATGPT_KEY_FILE="/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt"
chatgpt_pinned_key() {
printf '%s/setup/keys/RPM-GPG-KEY-chatgpt' "${PANAMA_PATH:-$HOME/.local/share/Panama}"
}
# The fingerprint of the pinned copy. Nonzero when it cannot be read at all,
# which the caller reports differently from a key that reads but is the wrong
# one.
chatgpt_pinned_fingerprint() {
local key
key="$(chatgpt_pinned_key)"
[[ -r "$key" ]] || return 1
gpg --show-keys --with-colons "$key" 2>/dev/null \
| awk -F: '$1 == "fpr" { print $10; exit }'
}
# Fails without touching anything when the pinned key is missing, unreadable,
# or not the key this repository says it is. Everything below assumes it passed.
chatgpt_verify_pinned_key() {
local found
if ! command -v gpg >/dev/null 2>&1; then
printf 'gpg is missing, so the pinned ChatGPT signing key cannot be verified.\n' >&2
return 1
fi
if ! found="$(chatgpt_pinned_fingerprint)"; then
printf 'The pinned ChatGPT signing key is missing: %s\n' "$(chatgpt_pinned_key)" >&2
return 1
fi
if [[ "$found" != "$CHATGPT_KEY_FINGERPRINT" ]]; then
printf 'The pinned ChatGPT signing key is %s, not the expected %s.\n' \
"${found:-unreadable}" "$CHATGPT_KEY_FINGERPRINT" >&2
return 1
fi
}
# Installs the verified key and the repository that names it, so the install
# after this one is a signature check rather than an act of faith.
#
# Takes the command that gets root, because the two callers ask for it
# differently: plain `sudo` from the installer, which authenticated once at the
# top of the run, and `panama-sudo --reason ...` from a migration, whose prompt
# has to say which repair it is for.
chatgpt_install_repository() {
local -a sudo_cmd=("$@")
(( ${#sudo_cmd[@]} > 0 )) || sudo_cmd=(sudo)
chatgpt_verify_pinned_key || return 1
"${sudo_cmd[@]}" install -D -m 0644 "$(chatgpt_pinned_key)" "$CHATGPT_KEY_FILE" || return 1
"${sudo_cmd[@]}" rpmkeys --import "$CHATGPT_KEY_FILE" || return 1
# Written here rather than left to the package's scriptlet, because the
# point of it is to exist -- with gpgcheck on and this key named -- before
# the first install rather than after it. Same base URL and same key the
# scriptlet writes, so it finds nothing to change later.
printf '%s\n' \
'[openai-chatgpt]' \
'name=ChatGPT' \
"baseurl=$CHATGPT_REPO_BASEURL" \
'enabled=1' \
'type=rpm-md' \
'gpgcheck=1' \
'repo_gpgcheck=1' \
"gpgkey=file://$CHATGPT_KEY_FILE" \
| "${sudo_cmd[@]}" tee "$CHATGPT_REPO_FILE" >/dev/null || return 1
}
+52 -31
View File
@@ -11,17 +11,24 @@ content before the applicable verification succeeds.
Each command below was run in a private temporary directory on 2026-08-27. Each command below was run in a private temporary directory on 2026-08-27.
The resulting armored public key is vendored under `keys/`; each output was The resulting armored public key is vendored under `keys/`; each output was
checked with the listed complete primary fingerprint before it was committed. checked with the listed complete primary fingerprint before it was committed.
The verification commands use Panama's status-preserving helper: it captures
GPG's output only after GPG succeeds, then requires exactly one primary key.
```bash
source setup/lib/artifact-provenance
key_fingerprint_matches KEY.asc EXPECTED_COMPLETE_PRIMARY_FINGERPRINT
```
| Key | Source URL | Expected primary fingerprint | Verification command | | Key | Source URL | Expected primary fingerprint | Verification command |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Terra 44 | `https://repos.fyralabs.com/terra44/key.asc` | `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` | `gpg --batch --with-colons --import-options show-only --import terra44.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | | Terra 44 | `https://repos.fyralabs.com/terra44/key.asc` | `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` | `key_fingerprint_matches terra44.asc AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` |
| Anthropic Claude Code | `https://downloads.claude.ai/keys/claude-code.asc` | `31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` | `gpg --batch --with-colons --import-options show-only --import claude-code.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | | Anthropic Claude Code | `https://downloads.claude.ai/keys/claude-code.asc` | `31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` | `key_fingerprint_matches claude-code.asc 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` |
| Bun releases | `https://keys.openpgp.org/vks/v1/by-fingerprint/F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `gpg --batch --with-colons --import-options show-only --import bun.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | | Bun releases | `https://keys.openpgp.org/vks/v1/by-fingerprint/F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `key_fingerprint_matches bun.asc F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` |
| RPM Fusion free | `https://download1.rpmfusion.org/free/fedora/RPM-GPG-KEY-rpmfusion-free-fedora-2020` | `E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` | `gpg --batch --with-colons --import-options show-only --import rpmfusion-free.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | | RPM Fusion free | `https://download1.rpmfusion.org/free/fedora/RPM-GPG-KEY-rpmfusion-free-fedora-2020` | `E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` | `key_fingerprint_matches rpmfusion-free.asc E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` |
| RPM Fusion nonfree | `https://download1.rpmfusion.org/nonfree/fedora/RPM-GPG-KEY-rpmfusion-nonfree-fedora-2020` | `79BDB88F9BBF73910FD4095B6A2AF96194843C65` | `gpg --batch --with-colons --import-options show-only --import rpmfusion-nonfree.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | | RPM Fusion nonfree | `https://download1.rpmfusion.org/nonfree/fedora/RPM-GPG-KEY-rpmfusion-nonfree-fedora-2020` | `79BDB88F9BBF73910FD4095B6A2AF96194843C65` | `key_fingerprint_matches rpmfusion-nonfree.asc 79BDB88F9BBF73910FD4095B6A2AF96194843C65` |
| lionheartp/Hyprland COPR | `https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/pubkey.gpg` | `97E23476C89635135407C7D5E9BA41342C4B2995` | `gpg --batch --with-colons --import-options show-only --import hyprland-copr.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | | lionheartp/Hyprland COPR | `https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/pubkey.gpg` | `97E23476C89635135407C7D5E9BA41342C4B2995` | `key_fingerprint_matches hyprland-copr.asc 97E23476C89635135407C7D5E9BA41342C4B2995` |
| Flathub | `https://flathub.org/repo/flathub.flatpakrepo` | `6E5C05D979C76DAF93C081354184DD4D907A7CAE` | `awk -F= '/^GPGKey=/{print $2}' flathub.flatpakrepo \| base64 --decode \| gpg --batch --with-colons --import-options show-only --import \| awk -F: '$1 == "fpr" { print $10; exit }'` | | Flathub | `https://flathub.org/repo/flathub.flatpakrepo` | `6E5C05D979C76DAF93C081354184DD4D907A7CAE` | `key_fingerprint_matches flathub.asc 6E5C05D979C76DAF93C081354184DD4D907A7CAE` |
| Claude Desktop Extra | `https://patrickjaja.github.io/claude-desktop-extra/gpg-key.asc` | `825A7D15D78BABE45646D5DF382409F597908867` | `gpg --batch --with-colons --import-options show-only --import claude-desktop.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | | Claude Desktop Extra | `https://patrickjaja.github.io/claude-desktop-extra/gpg-key.asc` | `825A7D15D78BABE45646D5DF382409F597908867` | `key_fingerprint_matches claude-desktop.asc 825A7D15D78BABE45646D5DF382409F597908867` |
The retrieval command for every direct key was: The retrieval command for every direct key was:
@@ -98,16 +105,28 @@ digest, then update the command and this ledger in a second commit.
Do not replace a key on an automated update. A key rotation is a reviewed Do not replace a key on an automated update. A key rotation is a reviewed
repository change: obtain the new key from the publisher record, independently repository change: obtain the new key from the publisher record, independently
confirm its complete primary fingerprint, update the vendored key and confirm its complete primary fingerprint, and update every independent pin site
`installers.conf` together, refresh this retrieval record, and add a focused in one review:
contract case if the verification behavior changes. Until that review lands,
verification fails closed and preserves any known-good destination.
## Container-only Terra 44 signed-bootstrap proof - the armored key under `setup/provenance/keys/`;
- its fingerprint in `setup/provenance/installers.conf`;
- the matching `_require_policy_value` literal in
`setup/scripts/install-packages`;
- independent fingerprint expectations and command-log fixtures in
`tests/setup/package-provenance-contract`;
- this retrieval and evidence ledger at `setup/provenance/README.md`.
On 2026-08-27, a single disposable rootless Podman container proved the Terra Until all sites agree, verification fails closed and preserves any known-good
bootstrap path without changing the host package database, host keyring, or destination. Add or update a focused contract whenever verification behavior
host repository files. Podman reported `rootless=true`, `runtime=crun`, and a changes.
## Historical container-only Terra 44 signed-bootstrap proof
On 2026-08-27, a single disposable rootless Podman container validated Terra's
then-reviewed signed bootstrap without changing the host package database,
host keyring, or host repository files. This is retained historical publisher
evidence; Panama's runtime installer no longer installs `terra-release`.
Podman reported `rootless=true`, `runtime=crun`, and a
user graph root. The fresh image was user graph root. The fresh image was
`registry.fedoraproject.org/fedora@sha256:62f199d1eb34170a7bb2277485676d89c0e91aae4086151c4043062cce51c77c` `registry.fedoraproject.org/fedora@sha256:62f199d1eb34170a7bb2277485676d89c0e91aae4086151c4043062cce51c77c`
(`sha256:87d8a4a90c0457689db68624cac1026fb2201cbdc1e99cc5455a8f8876118498`). (`sha256:87d8a4a90c0457689db68624cac1026fb2201cbdc1e99cc5455a8f8876118498`).
@@ -115,12 +134,13 @@ The container (`5fc8fa42bb85afb3b57b336ca29b58a32fad50d460583329a4e910cc29fb4d2d
had no mounts and was removed automatically after `podman stop`. had no mounts and was removed automatically after `podman stop`.
Before copying the only host file admitted to the container, Before copying the only host file admitted to the container,
`keys/terra44.asc`, this exact host check reported the complete primary `keys/terra44.asc`, this status-preserving host check accepted the complete
fingerprint `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F`: primary fingerprint `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F`:
```bash ```bash
gpg --batch --with-colons --import-options show-only --import setup/provenance/keys/terra44.asc \ source setup/lib/artifact-provenance
| awk -F: '$1 == "fpr" { print $10; exit }' key_fingerprint_matches setup/provenance/keys/terra44.asc \
AE09157A4DE88B497EA1D5D300CDAB43DE226D6F
``` ```
Its SHA-256 was Its SHA-256 was
@@ -144,14 +164,16 @@ podman exec panama-terra-proof-20260827 /bin/bash -lc '
' '
``` ```
Inside the container the copied and installed key both had the recorded The retained command output records the copied key's SHA-256 and DNF's
SHA-256 before and after installation. `terra-release-44-9.noarch` was successful `terra-release-44-9.noarch` transaction. The command itself pins the
installed. Its effective `terra` configuration reported `gpgcheck = 1`, temporary Terra base URL and local staged key and enables package and repository
`pkg_gpgcheck = 1`, and `repo_gpgcheck = 1`; no GPG-bypass option was used. signature checks. It does not include a separate post-install fingerprint or
The package's own `/etc/yum.repos.d/terra.repo` uses its Terra metalink and effective-repository query, so this ledger makes no independent post-check
`RPM-GPG-KEY-terra44`. That differs from Panama's deliberately staged local claim. Production publishes the reviewed root-staged key/repository pair
key/base-URL file in `install-packages`, which replaces the release-generated directly and commits it only after the effective-repository post-check
file only after this verified bootstrap step. succeeds; failure restores the prior pair. Publisher-only package transactions
use a fresh command-line repository identity, the reviewed base URL, and a
newly fingerprint-verified private root key snapshot.
Although the command runner returned after 30 seconds while DNF was still Although the command runner returned after 30 seconds while DNF was still
loading metadata, Podman's retained event log records the exact command's loading metadata, Podman's retained event log records the exact command's
@@ -165,6 +187,5 @@ podman events --since '2026-08-27T10:55:00-04:00' --until '2026-08-27T11:02:00-0
The first `exec` event, at `timeNano=1787842633591543881`, is the documented The first `exec` event, at `timeNano=1787842633591543881`, is the documented
key-install and DNF command. Its matching first `exec_died` event, at key-install and DNF command. Its matching first `exec_died` event, at
`timeNano=1787842671276003275`, records `ContainerExitCode:0`. The `timeNano=1787842671276003275`, records `ContainerExitCode:0`. No retry or
same-container post-check independently confirmed the installed package and second container was used, and no stronger post-check evidence is retained.
effective signature settings above; no retry or second container was used.
+6 -1
View File
@@ -53,7 +53,12 @@ if [[ "${PANAMA_NVIDIA:-no}" == yes ]]; then
warn "Secure Boot question, or disable Secure Boot first." warn "Secure Boot question, or disable Secure Boot first."
else else
log "Installing the NVIDIA driver" log "Installing the NVIDIA driver"
if sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda; then if sudo dnf install -y \
--repo=fedora --repo=updates \
--repo=rpmfusion-free --repo=rpmfusion-free-updates \
--repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates \
--from-repo=rpmfusion-nonfree,rpmfusion-nonfree-updates \
akmod-nvidia xorg-x11-drv-nvidia-cuda; then
# nouveau has to be out of the way before the kernel would otherwise # nouveau has to be out of the way before the kernel would otherwise
# bind it, which is why these are kernel arguments and not a modprobe # bind it, which is why these are kernel arguments and not a modprobe
# drop-in. modeset=1 is what makes the Wayland session work at all. # drop-in. modeset=1 is what makes the Wayland session work at all.
File diff suppressed because it is too large Load Diff
+20 -22
View File
@@ -11,11 +11,12 @@
# this repository, not anybody's personal content, so a stranger who clones # this repository, not anybody's personal content, so a stranger who clones
# Panama wants it for exactly the same reason its author does. # Panama wants it for exactly the same reason its author does.
# #
# ~/.agents/skills and ~/.claude/skills may each start as a single symlink into # ~/.claude/skills was a single symlink into user/agents/skills until now, and
# user/agents/skills, but a directory cannot point at personal and shipped # a directory cannot be two things at once. So the destination becomes a real
# skills at once. Both destinations become real directories with one link per # directory and every skill -- shipped here, personal from user/ -- is linked
# skill. link-user runs after this stage on purpose, so a personal skill named # into it one at a time. link-user runs after this stage on purpose: it links
# like a shipped one wins in every agent runtime. # last, so a personal skill named like a shipped one wins, which is the
# precedence Claude Code itself uses.
set -euo pipefail set -euo pipefail
@@ -24,7 +25,7 @@ log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
SKILLS_DIR="$PANAMA_PATH/skills" SKILLS_DIR="$PANAMA_PATH/skills"
PANAMA_OLD="$PANAMA_PATH/config/old" PANAMA_OLD="$PANAMA_PATH/config/old"
DESTINATIONS=("$HOME/.agents/skills" "$HOME/.claude/skills") DESTINATION="$HOME/.claude/skills"
[[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; } [[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; }
@@ -50,30 +51,27 @@ displace() {
log "Moved existing $destination to $backup" log "Moved existing $destination to $backup"
} }
# Each destination has to be a real directory before anything can be linked # The destination itself has to be a real directory before anything can be
# into it. An old whole-directory symlink is removed; a regular file somebody # linked into it. An old whole-directory symlink is removed; a regular file
# left at the path is kept in config/old/. # somebody left at this path is kept, in config/old/.
for destination in "${DESTINATIONS[@]}"; do mkdir -p "$(dirname "$DESTINATION")"
mkdir -p "$(dirname "$destination")" if [[ -L "$DESTINATION" ]]; then
if [[ -L "$destination" ]]; then rm -f "$DESTINATION"
rm -f "$destination" log "Removed the old $DESTINATION symlink; skills are linked one by one now"
log "Removed the old $destination symlink; skills are linked one by one now" elif [[ -e "$DESTINATION" && ! -d "$DESTINATION" ]]; then
elif [[ -e "$destination" && ! -d "$destination" ]]; then displace "$DESTINATION"
displace "$destination" fi
fi mkdir -p "$DESTINATION"
mkdir -p "$destination"
done
linked=0 linked=0
for skill in "$SKILLS_DIR"/*; do for skill in "$SKILLS_DIR"/*; do
[[ -e "$skill" ]] || continue [[ -e "$skill" ]] || continue
name="$(basename "$skill")" name="$(basename "$skill")"
for destination in "${DESTINATIONS[@]}"; do target="$DESTINATION/$name"
target="$destination/$name"
displace "$target" displace "$target"
ln -s "$skill" "$target" ln -s "$skill" "$target"
log "Linked skills/$name → $target" log "Linked skills/$name → $target"
done
linked=$(( linked + 1 )) linked=$(( linked + 1 ))
done done
+92 -5
View File
@@ -6,6 +6,75 @@
set -euo pipefail set -euo pipefail
_collect_vicinae_inputs() {
local extension="$1" output="$2"
[[ -d "$extension" && ! -L "$extension" \
&& -f "$extension/package.json" && ! -L "$extension/package.json" \
&& -f "$extension/package-lock.json" && ! -L "$extension/package-lock.json" ]] \
|| return 1
# Everything authored below the extension affects its build. npm's
# dependency tree is the sole exception and is reproduced from the lock.
find "$extension" -mindepth 1 \
\( -path "$extension/node_modules" -prune \) -o \
! -type d -print0 >"$output" || return 1
LC_ALL=C sort -z -o "$output" "$output" || return 1
}
_write_vicinae_manifest() {
local extension="$1" inputs="$2" output="$3"
local input relative digest
: >"$output" || return 1
while IFS= read -r -d '' input; do
[[ -f "$input" && ! -L "$input" && -r "$input" ]] || return 1
relative="${input#"$extension"/}"
[[ "$relative" != "$input" && -n "$relative" ]] || return 1
digest="$(sha256sum -- "$input" | awk '{ print $1 }')" || return 1
[[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1
printf '%s\0%s\0' "$relative" "$digest" >>"$output" || return 1
done <"$inputs"
}
_vicinae_extension_digest() (
local extension="${1%/}" work=""
trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
work="$(mktemp -u -d -t panama-vicinae-digest.XXXXXX)" || exit 1
if ! mkdir -m 700 -- "$work"; then
work=""
exit 1
fi
_collect_vicinae_inputs "$extension" "$work/inputs.before" || exit 1
_write_vicinae_manifest \
"$extension" "$work/inputs.before" "$work/manifest.before" || exit 1
_collect_vicinae_inputs "$extension" "$work/inputs.after" || exit 1
_write_vicinae_manifest \
"$extension" "$work/inputs.after" "$work/manifest.after" || exit 1
cmp -s -- "$work/inputs.before" "$work/inputs.after" || exit 1
cmp -s -- "$work/manifest.before" "$work/manifest.after" || exit 1
sha256sum -- "$work/manifest.before" | awk '{ print $1 }'
)
_record_vicinae_digest() (
local built="$1" digest="$2" receipt temporary=""
trap '[[ -z "$temporary" ]] || rm -f -- "$temporary"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
[[ -d "$built" && ! -L "$built" ]] || exit 1
receipt="$built/.panama-source-sha256"
temporary="$(mktemp -u "$built/.panama-source-sha256.XXXXXX")" || exit 1
umask 077
if ! (set -o noclobber; : >"$temporary") 2>/dev/null; then
temporary=""
exit 1
fi
printf '%s\n' "$digest" >"$temporary" || exit 1
mv -f -- "$temporary" "$receipt" || exit 1
temporary=""
)
panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}" panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}"
vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}" vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}"
source_dir="$panama_path/config/local/share/vicinae/scripts" source_dir="$panama_path/config/local/share/vicinae/scripts"
@@ -81,18 +150,36 @@ if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then
[[ -f "$extension/package.json" ]] || continue [[ -f "$extension/package.json" ]] || continue
name="$(basename "$extension")" name="$(basename "$extension")"
# Skip a build that would produce what is already there. `npm ci` # Skip only when a prior successful build records the digest of both
# alone takes long enough to be worth not repeating on every re-run of # manifests and every source byte. Directory mtimes do not change when
# a stage that is otherwise nearly instant. # an existing source file is edited.
built="$vicinae_data_dir/extensions/$name" built="$vicinae_data_dir/extensions/$name"
if [[ -d "$built" && "$extension/src" -ot "$built" ]]; then receipt="$built/.panama-source-sha256"
if ! source_digest="$(_vicinae_extension_digest "$extension")"; then
printf 'Vicinae extension %s inputs could not be verified; skipping\n' \
"$name" >&2
continue
fi
if [[ -f "$receipt" && ! -L "$receipt" ]] \
&& cmp -s <(printf '%s\n' "$source_digest") "$receipt"; then
printf 'Vicinae extension %s is already built\n' "$name" printf 'Vicinae extension %s is already built\n' "$name"
continue continue
fi fi
printf 'Building Vicinae extension %s\n' "$name" printf 'Building Vicinae extension %s\n' "$name"
if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 \
&& npm run build >/dev/null 2>&1); then
printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2 printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2
continue
fi
if ! final_digest="$(_vicinae_extension_digest "$extension")" \
|| [[ "$final_digest" != "$source_digest" ]]; then
printf 'Vicinae extension %s changed while building; receipt withheld\n' \
"$name" >&2
continue
fi
if ! _record_vicinae_digest "$built" "$source_digest"; then
printf 'Vicinae extension %s receipt could not be recorded\n' "$name" >&2
fi fi
done done
elif [[ -d "$extensions_source" ]]; then elif [[ -d "$extensions_source" ]]; then
-1
View File
@@ -247,7 +247,6 @@ hermetic tests/server/containers-shape-contract
hermetic tests/server/panama-server-contract hermetic tests/server/panama-server-contract
hermetic tests/setup/apps-contract hermetic tests/setup/apps-contract
hermetic tests/setup/boot-contract hermetic tests/setup/boot-contract
hermetic tests/setup/chatgpt-package-contract
hermetic tests/setup/contract-manifest-contract hermetic tests/setup/contract-manifest-contract
hermetic tests/setup/crash-watch-contract hermetic tests/setup/crash-watch-contract
hermetic tests/setup/desktop-first-contract hermetic tests/setup/desktop-first-contract
+2
View File
@@ -32,6 +32,8 @@ panama="$repo_dir/bin/panama"
findings=() findings=()
note() { findings+=("$1"); } note() { findings+=("$1"); }
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
shopt -s nullglob shopt -s nullglob
definitions=("$apps_dir"/*) definitions=("$apps_dir"/*)
+146
View File
@@ -15,6 +15,16 @@ note() { findings+=("$1"); }
[[ -x "$boot" ]] || { printf 'boot contract: %s is not executable\n' "$boot" >&2; exit 1; } [[ -x "$boot" ]] || { printf 'boot contract: %s is not executable\n' "$boot" >&2; exit 1; }
# Git is the only package boot can install before the verified checkout exists.
# Both root-server and ordinary-user paths must exclude ambient third-party
# repositories while still allowing Fedora dependencies.
for git_install in \
'dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git' \
'sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git'; do
grep -qF "$git_install" "$boot" \
|| note "boot omits reviewed Fedora source binding: $git_install"
done
work="$(mktemp -d)" work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
@@ -103,6 +113,16 @@ case "\${1:-}" in
[[ "\$#" -eq 4 && "\$4" == 'HEAD^{commit}' ]] || exit 97 [[ "\$#" -eq 4 && "\$4" == 'HEAD^{commit}' ]] || exit 97
cat "$state/head-revision" cat "$state/head-revision"
;; ;;
ls-tree)
[[ "\$#" -eq 6 && "\$4" == -rz && "\$5" == --full-tree \
&& "\$6" == "$revision" ]] || exit 97
printf '100755 blob aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tinstall\0'
;;
hash-object)
[[ "\$#" -eq 6 && "\$4" == --no-filters && "\$5" == -- \
&& "\$6" == install ]] || exit 97
printf '%s\n' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
;;
*) exit 97 ;; *) exit 97 ;;
esac esac
;; ;;
@@ -268,6 +288,132 @@ run_boot "$revision" "$boot_sha"
(( run_status != 0 )) || note 'existing HEAD mismatch returned success' (( run_status != 0 )) || note 'existing HEAD mismatch returned success'
assert_no_install_or_rewrite 'existing HEAD mismatch' assert_no_install_or_rewrite 'existing HEAD mismatch'
# Git's porcelain status deliberately trusts index hints. The bootstrap cannot:
# these two flags can hide changed executable bytes while HEAD still names the
# reviewed commit. Exercise real Git so the contract cannot accidentally teach
# its adapter to expose state that Git itself hides.
real_git="$(command -v git)"
hidden_root="$work/hidden-index"
mkdir -p "$hidden_root/home"
"$real_git" init -q "$hidden_root/source"
"$real_git" -C "$hidden_root/source" config user.email contract@panama
"$real_git" -C "$hidden_root/source" config user.name contract
printf '#!/usr/bin/env bash\nexit 0\n' >"$hidden_root/source/install"
chmod +x "$hidden_root/source/install"
printf 'trusted target bytes\n' >"$hidden_root/source/target"
ln -s target "$hidden_root/source/trusted-link"
"$real_git" -C "$hidden_root/source" add install target trusted-link
"$real_git" -C "$hidden_root/source" commit -qm trusted
hidden_revision="$("$real_git" -C "$hidden_root/source" rev-parse HEAD)"
"$real_git" clone -q --bare "$hidden_root/source" "$hidden_root/origin.git"
# Exercise the exact boundary between checkout preparation and handoff. This
# test-only copy inserts a same-UID replacement after prepare returns; the
# production handoff must perform its complete comparison after that point.
post_prepare_checkout="$hidden_root/post-prepare-swap"
post_prepare_marker="$hidden_root/post-prepare-executed"
post_prepare_hook_marker="$hidden_root/post-prepare-hook-fired"
"$real_git" clone -q "$hidden_root/origin.git" "$post_prepare_checkout"
post_prepare_hook="$hidden_root/swap-install"
cat >"$post_prepare_hook" <<'HOOK'
#!/usr/bin/env bash
: >"$PANAMA_BOOT_POST_PREPARE_HOOK_MARKER"
printf '#!/usr/bin/env bash\nprintf "executed\\n" >%q\n' \
"$PANAMA_BOOT_POST_PREPARE_MARKER" >"$PANAMA_PATH/install"
chmod +x "$PANAMA_PATH/install"
HOOK
chmod +x "$post_prepare_hook"
hooked_boot="$hidden_root/boot-post-prepare-hook"
awk '
{
print
if ($0 == "prepare_panama_checkout \"$PANAMA_PATH\"") {
prepare_count++
if (prepare_count == 1) print "\"$PANAMA_BOOT_POST_PREPARE_FIXTURE\""
}
}
' "$boot" >"$hooked_boot"
hooked_boot_sha="$(sha256sum "$hooked_boot" | cut -d' ' -f1)"
post_prepare_status=0
HOME="$hidden_root/home" PANAMA_PATH="$post_prepare_checkout" \
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$hooked_boot_sha" \
PANAMA_BOOT_POST_PREPARE_FIXTURE="$post_prepare_hook" \
PANAMA_BOOT_POST_PREPARE_MARKER="$post_prepare_marker" \
PANAMA_BOOT_POST_PREPARE_HOOK_MARKER="$post_prepare_hook_marker" \
bash "$hooked_boot" </dev/null >"$hidden_root/post-prepare.out" 2>&1 \
|| post_prepare_status=$?
[[ -e "$post_prepare_hook_marker" ]] \
|| note 'post-prepare replacement hook did not exercise the boundary'
(( post_prepare_status != 0 )) \
|| note 'post-prepare worktree replacement returned success'
[[ ! -e "$post_prepare_marker" ]] \
|| note 'post-prepare worktree replacement executed unreviewed install bytes'
# A valid tracked symlink must compare its link text with Git's 120000 blob;
# hashing the pathname would follow it and hash the target file instead.
symlink_checkout="$hidden_root/tracked-symlink"
"$real_git" clone -q "$hidden_root/origin.git" "$symlink_checkout"
symlink_status=0
HOME="$hidden_root/home" PANAMA_PATH="$symlink_checkout" \
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \
bash "$boot" </dev/null >"$hidden_root/tracked-symlink.out" 2>&1 \
|| symlink_status=$?
(( symlink_status == 0 )) \
|| note 'a checkout with a valid tracked symlink was rejected'
for hidden_flag in assume-unchanged skip-worktree; do
hidden_checkout="$hidden_root/$hidden_flag"
hidden_marker="$hidden_root/$hidden_flag-executed"
"$real_git" clone -q "$hidden_root/origin.git" "$hidden_checkout"
printf '#!/usr/bin/env bash\nprintf "executed\\n" >%q\n' "$hidden_marker" \
>"$hidden_checkout/install"
chmod +x "$hidden_checkout/install"
"$real_git" -C "$hidden_checkout" update-index "--$hidden_flag" install
[[ -z "$("$real_git" -C "$hidden_checkout" status --porcelain)" ]] \
|| note "$hidden_flag fixture was not hidden from porcelain status"
hidden_status=0
HOME="$hidden_root/home" PANAMA_PATH="$hidden_checkout" \
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \
bash "$boot" </dev/null >"$hidden_root/$hidden_flag.out" 2>&1 \
|| hidden_status=$?
(( hidden_status != 0 )) \
|| note "$hidden_flag modified checkout returned success"
[[ ! -e "$hidden_marker" ]] \
|| note "$hidden_flag modified checkout executed unreviewed install bytes"
done
# The same hidden-index state must not conceal a mode change or a different
# symlink target; both are part of the reviewed Git tree, not metadata hints.
for hidden_flag in assume-unchanged skip-worktree; do
for hidden_change in mode symlink-target; do
hidden_checkout="$hidden_root/$hidden_flag-$hidden_change"
"$real_git" clone -q "$hidden_root/origin.git" "$hidden_checkout"
case "$hidden_change" in
mode)
chmod -x "$hidden_checkout/install"
hidden_path=install
;;
symlink-target)
rm -- "$hidden_checkout/trusted-link"
ln -s untrusted-target "$hidden_checkout/trusted-link"
hidden_path=trusted-link
;;
esac
"$real_git" -C "$hidden_checkout" update-index "--$hidden_flag" "$hidden_path"
[[ -z "$("$real_git" -C "$hidden_checkout" status --porcelain)" ]] \
|| note "$hidden_flag $hidden_change fixture was not hidden from porcelain status"
hidden_status=0
HOME="$hidden_root/home" PANAMA_PATH="$hidden_checkout" \
PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \
bash "$boot" </dev/null >"$hidden_root/$hidden_flag-$hidden_change.out" 2>&1 \
|| hidden_status=$?
(( hidden_status != 0 )) \
|| note "$hidden_flag concealed a tracked $hidden_change change"
done
done
if (( ${#findings[@]} > 0 )); then if (( ${#findings[@]} > 0 )); then
printf 'boot contract: %d finding(s)\n' "${#findings[@]}" >&2 printf 'boot contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2
-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env bash
# The one download that gets to run as root, and how it earns that.
#
# OpenAI publishes no signing key and no fingerprint that a first install could
# fetch and compare against: the documented instructions are to download an RPM
# and install it, and that RPM's own root scriptlet is what decides afterwards
# which repository and which key the machine will trust. Panama pins the key
# instead -- setup/keys/ carries it, setup/lib/chatgpt-package verifies the copy
# and writes the repository -- so dnf checks a signature before root sees a byte
# of it.
#
# What must hold:
#
# 1. The pinned key is the key the library says it is. Everything else here
# is worthless if this drifts, and a changed key must be a failing test
# somebody reads rather than a quiet change of publisher.
# 2. A pinned key that is missing, unreadable, or simply not that key stops
# the install and leaves the machine untouched. Failing closed is the
# whole point; falling back to installing anyway would be worse than
# never having checked.
# 3. What it writes actually enforces the check: gpgcheck and repo_gpgcheck
# on, and the gpgkey pointing at the key it just installed.
# 4. Both callers go through it, and neither hands root a downloaded RPM.
# The installer and the codex-desktop migration install `chatgpt` by name
# from that repository, which is what makes the signature mandatory.
#
# Hermetic: the key file is read locally, root is a stub that records what it
# was asked to do, and the destinations are redirected into a temporary
# directory. Nothing here contacts OpenAI or touches /etc.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
library="$repo_dir/setup/lib/chatgpt-package"
installer="$repo_dir/setup/scripts/install-packages"
migration="$repo_dir/migrations/1787804505.sh"
pinned_key="$repo_dir/setup/keys/RPM-GPG-KEY-chatgpt"
findings=()
note() { findings+=("$1"); }
[[ -r "$library" ]] || {
printf 'chatgpt package contract: %s is missing\n' "$library" >&2
exit 1
}
command -v gpg >/dev/null 2>&1 || {
printf 'chatgpt package contract: gpg is required to read the pinned key\n' >&2
exit 1
}
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# Root, as a recording stub. It logs the command and then runs it for real,
# which is safe because every destination below is redirected into $work.
stub="$work/bin"
mkdir -p "$stub"
cat >"$stub/sudo" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$SUDO_RECORD"
exec "$@"
STUB
cat >"$stub/rpmkeys" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$RPMKEYS_RECORD"
STUB
chmod +x "$stub/sudo" "$stub/rpmkeys"
export PATH="$stub:$PATH"
# ── 1. The pinned key is the pinned key ─────────────────────────────────────
if [[ ! -r "$pinned_key" ]]; then
note 'setup/keys/RPM-GPG-KEY-chatgpt is missing, so nothing can be verified'
else
declared="$(grep -oP '(?<=^CHATGPT_KEY_FINGERPRINT=")[0-9A-F]+' "$library" | head -1)"
actual="$(gpg --show-keys --with-colons "$pinned_key" 2>/dev/null \
| awk -F: '$1 == "fpr" { print $10; exit }')"
[[ -n "$declared" ]] \
|| note 'the library pins no fingerprint, so any key file would be accepted'
[[ -n "$actual" ]] \
|| note 'the pinned key file does not parse as a public key'
[[ "$declared" == "$actual" ]] \
|| note "the pinned key is $actual but the library expects $declared"
grep -q 'RPM-GPG-KEY-chatgpt' "$repo_dir/setup/keys/README.md" 2>/dev/null \
|| note 'setup/keys/README.md does not record where the pinned key came from'
grep -q "$actual" "$repo_dir/setup/keys/README.md" 2>/dev/null \
|| note 'setup/keys/README.md records a fingerprint other than the key it ships'
fi
# One attempt, against redirected destinations and a recording root. Every
# variable the library exposes is set here rather than in the caller's shell,
# so a case cannot leak into the next one.
attempt() {
local dir="$1" panama_path="$2" fingerprint="${3:-}"
mkdir -p "$dir"
(
export SUDO_RECORD="$dir/sudo.log" RPMKEYS_RECORD="$dir/rpmkeys.log"
: >"$SUDO_RECORD"
: >"$RPMKEYS_RECORD"
PANAMA_PATH="$panama_path"
# shellcheck source=/dev/null
source "$library"
CHATGPT_KEY_FILE="$dir/pki/RPM-GPG-KEY-chatgpt"
CHATGPT_REPO_FILE="$dir/repos/chatgpt.repo"
mkdir -p "$dir/repos"
[[ -z "$fingerprint" ]] || CHATGPT_KEY_FINGERPRINT="$fingerprint"
chatgpt_install_repository sudo
) >"$dir/out" 2>&1
}
# ── 2. It fails closed ──────────────────────────────────────────────────────
# A checkout with no pinned key at all.
empty="$work/no-key"
mkdir -p "$empty/checkout/setup/keys"
attempt "$empty" "$empty/checkout" \
&& note 'a missing pinned key still established the repository'
[[ ! -e "$empty/repos/chatgpt.repo" ]] \
|| note 'a missing pinned key still wrote a repository file'
grep -qi 'missing' "$empty/out" \
|| note 'a missing pinned key does not say so'
# A key file that is not a key.
garbage="$work/garbage-key"
mkdir -p "$garbage/checkout/setup/keys"
printf 'not a key\n' >"$garbage/checkout/setup/keys/RPM-GPG-KEY-chatgpt"
attempt "$garbage" "$garbage/checkout" \
&& note 'an unreadable pinned key still established the repository'
[[ ! -e "$garbage/repos/chatgpt.repo" ]] \
|| note 'an unreadable pinned key still wrote a repository file'
# The real key, against a fingerprint that is not its own -- the shape a
# substituted publisher would take.
wrong="$work/wrong-fingerprint"
attempt "$wrong" "$repo_dir" '0000000000000000000000000000000000000000' \
&& note 'a key that does not match the pinned fingerprint was accepted'
[[ ! -e "$wrong/repos/chatgpt.repo" ]] \
|| note 'a fingerprint mismatch still wrote a repository file'
[[ ! -s "$wrong/rpmkeys.log" ]] \
|| note 'a fingerprint mismatch still imported the key into the rpm keyring'
# ── 3. What it writes enforces the check ────────────────────────────────────
good="$work/verified"
if ! attempt "$good" "$repo_dir"; then
note "the pinned key was rejected: $(cat "$good/out")"
else
repo_file="$good/repos/chatgpt.repo"
key_file="$good/pki/RPM-GPG-KEY-chatgpt"
cmp -s "$key_file" "$pinned_key" \
|| note 'the installed key is not the pinned key'
grep -q 'import' "$good/rpmkeys.log" \
|| note 'the verified key was never imported, so dnf has nothing to check against'
grep -qx 'gpgcheck=1' "$repo_file" \
|| note 'the repository does not set gpgcheck=1, so package signatures go unchecked'
grep -qx 'repo_gpgcheck=1' "$repo_file" \
|| note 'the repository does not set repo_gpgcheck=1, so the metadata goes unchecked'
grep -qx "gpgkey=file://$key_file" "$repo_file" \
|| note 'the repository does not point gpgkey at the key that was just installed'
grep -q 'baseurl=https://' "$repo_file" \
|| note 'the repository has no https base URL'
fi
# ── 4. Both callers go through it ───────────────────────────────────────────
for caller in "$installer" "$migration"; do
name="${caller#"$repo_dir"/}"
[[ -r "$caller" ]] || { note "$name is missing"; continue; }
grep -q 'setup/lib/chatgpt-package' "$caller" \
|| note "$name does not source the verified install library"
grep -q 'chatgpt_install_repository' "$caller" \
|| note "$name does not establish the verified repository before installing"
grep -qE 'dnf install -y chatgpt\b' "$caller" \
|| note "$name does not install chatgpt by name from that repository"
# The shape this contract exists to keep out: fetch an RPM, hand it to
# root, and let its scriptlet decide what the machine trusts afterwards.
grep -qE 'curl.*chatgpt.*\.rpm' "$caller" \
&& note "$name downloads a ChatGPT RPM instead of installing it from the verified repository"
grep -qE 'dnf install[^|]*\$\{?chatgpt_rpm' "$caller" \
&& note "$name installs a downloaded ChatGPT RPM as root"
done
if (( ${#findings[@]} > 0 )); then
printf 'chatgpt package contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'chatgpt package contract: PASS\n'
+1 -7
View File
@@ -11,22 +11,16 @@ manifest="$repo_dir/tests/contracts.manifest"
discover_contracts() { discover_contracts() {
discovered_contracts=() discovered_contracts=()
# Byte order, exactly as the runner discovers them. A UTF-8 collation folds
# the punctuation away and reorders the pairs that differ only by `-` and
# `_`, so a manifest correct here would be wrong on a machine with a
# different LANG.
while IFS= read -r path; do while IFS= read -r path; do
[[ -x "$path" || "$path" == *_test.py ]] || continue [[ -x "$path" || "$path" == *_test.py ]] || continue
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}") discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
done < <(find "$repo_dir/tests" -type f \ done < <(find "$repo_dir/tests" -type f \
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort) -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
} }
validate_manifest() { validate_manifest() {
local candidate="$1" local candidate="$1"
local -n expected_contracts="$2" local -n expected_contracts="$2"
# Byte order, for the same reason discover_contracts sorts in it.
local LC_ALL=C
local line capabilities path extra previous_comment="" previous_was_comment=0 local line capabilities path extra previous_comment="" previous_was_comment=0
local -a capability_list=() local -a capability_list=()
local -A manifest_paths=() capability_counts=() local -A manifest_paths=() capability_counts=()
+3 -2
View File
@@ -99,9 +99,10 @@ sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q
# Comments dropped and backslash continuations joined, so a `soft` invocation # Comments dropped and backslash continuations joined, so a `soft` invocation
# wrapped across three lines reads as the one command it is. # wrapped across three lines reads as the one command it is.
uncommented() { grep -vE '^\s*#' "$installer" | sed -e :a -e '/\\$/N; s/\\\n\s*/ /; ta'; } uncommented() { grep -vE '^\s*#' "$installer" | sed -e :a -e '/\\$/N; s/\\\n\s*/ /; ta'; }
uncommented_installer="$(uncommented)"
while read -r command; do while read -r command; do
uncommented | grep -q "soft .*$command" \ grep -q "soft .*$command" <<<"$uncommented_installer" \
|| note "'$command' runs without soft, so its failure still ends the stage" || note "'$command' runs without soft, so its failure still ends the stage"
done <<'FRAGILE' done <<'FRAGILE'
dnf swap -y 'ffmpeg-free' dnf swap -y 'ffmpeg-free'
@@ -129,7 +130,7 @@ if "rpm -q hyprland" not in after or "exit 1" not in after:
raise SystemExit(1) raise SystemExit(1)
PY PY
uncommented | grep -q 'soft .*HYPR_PACKAGES' \ grep -q 'soft .*HYPR_PACKAGES' <<<"$uncommented_installer" \
&& note 'the Hyprland install is tolerated, so a machine with no desktop reports success' && note 'the Hyprland install is tolerated, so a machine with no desktop reports success'
# ── Soft failures are reported ────────────────────────────────────────────── # ── Soft failures are reported ──────────────────────────────────────────────
+4 -1
View File
@@ -103,10 +103,13 @@ fi
nvidia="$(run_stage PANAMA_NVIDIA=yes)" nvidia="$(run_stage PANAMA_NVIDIA=yes)"
called "$nvidia" 'dnf install -y akmod-nvidia' \ called "$nvidia" 'akmod-nvidia' \
|| note 'answering yes to NVIDIA does not install akmod-nvidia' || note 'answering yes to NVIDIA does not install akmod-nvidia'
called "$nvidia" 'xorg-x11-drv-nvidia-cuda' \ called "$nvidia" 'xorg-x11-drv-nvidia-cuda' \
|| note 'the CUDA driver is not installed alongside the kernel module' || note 'the CUDA driver is not installed alongside the kernel module'
expected_nvidia='sudo dnf install -y --repo=fedora --repo=updates --repo=rpmfusion-free --repo=rpmfusion-free-updates --repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates --from-repo=rpmfusion-nonfree,rpmfusion-nonfree-updates akmod-nvidia xorg-x11-drv-nvidia-cuda'
grep -Fxq -- "$expected_nvidia" <<<"$nvidia" \
|| note 'the NVIDIA transaction is not limited to reviewed Fedora and RPM Fusion repositories'
called "$nvidia" 'grubby --update-kernel=ALL' \ called "$nvidia" 'grubby --update-kernel=ALL' \
|| note 'the kernel arguments are never set' || note 'the kernel arguments are never set'
called "$nvidia" 'modprobe.blacklist=nouveau' \ called "$nvidia" 'modprobe.blacklist=nouveau' \
+126 -1
View File
@@ -123,7 +123,7 @@ grep -q '/etc/profile.d/nvm.sh' "$stage" \
|| note 'the extension build never sources nvm, so npm is missing on any machine without a system node' || note 'the extension build never sources nvm, so npm is missing on any machine without a system node'
# node_modules is a dependency tree, not configuration. # node_modules is a dependency tree, not configuration.
git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \ git -C "$repo_dir" check-ignore -q "$extension/node_modules/" 2>/dev/null \
|| note 'the extension node_modules is not gitignored' || note 'the extension node_modules is not gitignored'
# npm must honour the committed dependency graph. This disposable fixture # npm must honour the committed dependency graph. This disposable fixture
@@ -167,6 +167,131 @@ stage_output="$(PATH="$fixture_root/bin:$PATH" PANAMA_PATH="$fixture_root" \
cmp -s -- "$lock_before" "$lockfile" \ cmp -s -- "$lock_before" "$lockfile" \
|| note 'a rejected Vicinae lockfile mismatch changed package-lock.json' || note 'a rejected Vicinae lockfile mismatch changed package-lock.json'
# Successful builds carry a digest receipt over both manifests and every
# source file. Directory mtimes do not change when an existing file is edited,
# so each byte class must independently invalidate the build.
digest_root="$fixture_root/digest"
digest_extension="$digest_root/config/local/share/vicinae/extensions/panama-search"
digest_data="$digest_root/vicinae-data"
mkdir -p "$digest_root/config/local/share/vicinae/scripts" \
"$digest_extension/src" "$digest_extension/assets" "$digest_root/bin"
cp -- "$manifest" "$digest_extension/package.json"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/package-lock.json" \
"$digest_extension/package-lock.json"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/src/search.tsx" \
"$digest_extension/src/search.tsx"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/tsconfig.json" \
"$digest_extension/tsconfig.json"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg" \
"$digest_extension/assets/extension_icon.svg"
cat >"$digest_root/bin/npm" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"${NPM_LOG:?}"
case "${1:-}:${2:-}" in
ci:--silent) exit 0 ;;
run:build)
mkdir -p "$VICINAE_DATA_DIR/extensions/$(basename "$PWD")"
printf 'built\n' >"$VICINAE_DATA_DIR/extensions/$(basename "$PWD")/bundle"
;;
*) exit 64 ;;
esac
EOF
cat >"$digest_root/bin/find" <<'EOF'
#!/usr/bin/env bash
set -uo pipefail
status=0
/usr/bin/find "$@" || status=$?
[[ "${STUB_FIND_FAIL:-0}" != 1 ]] || exit 74
exit "$status"
EOF
chmod +x "$digest_root/bin/npm" "$digest_root/bin/find"
run_digest_stage() {
: >"$digest_root/npm.log"
PATH="$digest_root/bin:$PATH" PANAMA_PATH="$digest_root" \
VICINAE_DATA_DIR="$digest_data" NPM_LOG="$digest_root/npm.log" \
STUB_FIND_FAIL="${STUB_FIND_FAIL:-0}" \
bash "$stage" >"$digest_root/stage.out" 2>&1
}
run_digest_stage || note 'the Vicinae digest fixture initial build failed'
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|| note 'the Vicinae digest fixture did not perform its initial locked build'
run_digest_stage || note 'the unchanged Vicinae digest fixture failed'
[[ ! -s "$digest_root/npm.log" ]] \
|| note 'an unchanged Vicinae extension rebuilt despite its matching receipt'
for digest_input in src/search.tsx package.json package-lock.json tsconfig.json \
assets/extension_icon.svg; do
printf '\n// digest mutation: %s\n' "$digest_input" >>"$digest_extension/$digest_input"
run_digest_stage || note "the Vicinae digest fixture failed after changing $digest_input"
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|| note "changing existing $digest_input bytes did not rebuild the Vicinae extension"
done
# A traversal can emit valid-looking partial output and still fail. Sorting
# that output must not hide find's producer status or replace the successful
# build receipt with a digest over an incomplete source tree.
digest_receipt="$digest_data/extensions/panama-search/.panama-source-sha256"
cp -- "$digest_receipt" "$digest_root/receipt.before-find-failure"
STUB_FIND_FAIL=1 run_digest_stage \
|| note 'the Vicinae stage made a digest traversal failure fatal'
[[ ! -s "$digest_root/npm.log" ]] \
|| note 'a failed Vicinae digest traversal still rebuilt the extension'
grep -q 'inputs could not be verified; skipping' "$digest_root/stage.out" \
|| note 'a failed Vicinae digest traversal was accepted as verified input'
cmp -s -- "$digest_root/receipt.before-find-failure" "$digest_receipt" \
|| note 'a failed Vicinae digest traversal replaced the successful receipt'
# Helper writes run in conditional contexts in production, where Bash disables
# implicit errexit inside the whole function. Each producer therefore has to
# return its own write/publication failure and remove its temporary receipt.
vicinae_helpers="$digest_root/vicinae-helpers"
sed '/^panama_path=/,$d' "$stage" >"$vicinae_helpers"
: >"$digest_root/empty-inputs"
mkdir "$digest_root/manifest-output-directory"
manifest_status=0
bash -c 'source "$1"; set +e; _write_vicinae_manifest "$2" "$3" "$4"' bash \
"$vicinae_helpers" "$digest_extension" "$digest_root/empty-inputs" \
"$digest_root/manifest-output-directory" >/dev/null 2>&1 \
|| manifest_status=$?
[[ "$manifest_status" -ne 0 ]] \
|| note 'a failed Vicinae manifest initialization returned success'
receipt_failure_root="$digest_root/receipt-publication-failure"
mkdir -p "$receipt_failure_root/built" "$receipt_failure_root/bin"
printf 'prior receipt\n' >"$receipt_failure_root/built/.panama-source-sha256"
cat >"$receipt_failure_root/bin/mv" <<'EOF'
#!/usr/bin/env bash
destination="${!#}"
[[ "$destination" != */.panama-source-sha256 ]] || exit 75
exec /usr/bin/mv "$@"
EOF
chmod +x "$receipt_failure_root/bin/mv"
receipt_status=0
PATH="$receipt_failure_root/bin:$PATH" bash -c \
'source "$1"; set +e; _record_vicinae_digest "$2" "$3"' bash \
"$vicinae_helpers" "$receipt_failure_root/built" "$(printf 'a%.0s' {1..64})" \
>/dev/null 2>&1 || receipt_status=$?
[[ "$receipt_status" -ne 0 ]] \
|| note 'a failed Vicinae receipt publication returned success'
cmp -s <(printf 'prior receipt\n') \
"$receipt_failure_root/built/.panama-source-sha256" \
|| note 'a failed Vicinae receipt publication replaced the prior receipt'
[[ -z "$(find "$receipt_failure_root/built" \
-name '.panama-source-sha256.*' -print -quit)" ]] \
|| note 'a failed Vicinae receipt publication left a temporary receipt'
# Prove the directory-only ignore rule in a repository where node_modules does
# not already exist. The trailing slash is part of the query contract.
ignore_root="$fixture_root/ignore-repository"
mkdir -p "$ignore_root/config/local/share/vicinae/extensions/panama-search"
cp -- "$repo_dir/.gitignore" "$ignore_root/.gitignore"
git -C "$ignore_root" init -q
git -C "$ignore_root" check-ignore -q \
'config/local/share/vicinae/extensions/panama-search/node_modules/' \
|| note 'a fresh clone with no node_modules directory does not match the ignore rule'
# ── Report ─────────────────────────────────────────────────────────────────── # ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then if (( ${#findings[@]} > 0 )); then
File diff suppressed because it is too large Load Diff
@@ -187,6 +187,18 @@ case "${1:-}" in
*) exit 97 ;; *) exit 97 ;;
esac esac
;; ;;
ls-tree)
[[ "$#" -eq 6 && "$4" == -rz && "$5" == --full-tree \
&& "$6" == "$PANAMA_BOOT_REVISION" ]] || exit 97
object_id="$(/usr/bin/git hash-object --no-filters -- \
"$PANAMA_BOOT_FIXTURE_ROOT/stub-install")" || exit 97
printf '100755 blob %s\tinstall\0' "$object_id"
;;
hash-object)
[[ "$#" -eq 6 && "$4" == --no-filters && "$5" == -- \
&& "$6" == install ]] || exit 97
/usr/bin/git hash-object --no-filters -- "$2/$6"
;;
*) exit 97 ;; *) exit 97 ;;
esac esac
;; ;;
+20 -41
View File
@@ -2,7 +2,7 @@
# The manual this machine hands an agent. # The manual this machine hands an agent.
# #
# skills/ and .agents/skills/panama exist because an agent asked to do anything # skills/ and .claude/skills/panama exist because an agent asked to do anything
# on a Panama desktop will otherwise infer it from the source and get half of it # on a Panama desktop will otherwise infer it from the source and get half of it
# wrong. That only helps if what the skills say is true -- and a skill is worse # wrong. That only helps if what the skills say is true -- and a skill is worse
# than no skill when it is stale, because an agent believes it verbatim and does # than no skill when it is stale, because an agent believes it verbatim and does
@@ -16,7 +16,7 @@
# That is a convention on the prose -- name things exactly, in backticks -- # That is a convention on the prose -- name things exactly, in backticks --
# and it is how they should be written anyway. # and it is how they should be written anyway.
# 3. The delivery works: link-skills is a stage, in the right place, and the # 3. The delivery works: link-skills is a stage, in the right place, and the
# personal manifest hands both shared skill homes to the linkdir kind. # personal manifest hands ~/.claude/skills over to the linkdir kind.
# #
# Sections 1 and 2 report clearly and keep going when a skill is not written # Sections 1 and 2 report clearly and keep going when a skill is not written
# yet, so this contract is useful while the skills are still being authored. # yet, so this contract is useful while the skills are still being authored.
@@ -32,21 +32,9 @@ manifest="$repo_dir/user/manifest"
findings=() findings=()
note() { findings+=("$1"); } note() { findings+=("$1"); }
# Every directory under skills/ ships to every machine. The project-level # The three, and where each is delivered from. The two under skills/ are shipped
# panama skill stays in this repository and needs no home-directory delivery. # to every machine; the third is project-level and needs no delivery at all.
SKILL_DIRS=() SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama)
for directory in "$repo_dir"/skills/*; do
[[ -d "$directory" ]] && SKILL_DIRS+=("${directory#"$repo_dir"/}")
done
SKILL_DIRS+=(.agents/skills/panama)
# The project skill has one agent-neutral source. Claude gets a compatibility
# symlink, while Codex and other Agent Skills readers use .agents directly.
[[ -L "$repo_dir/.claude/skills/panama" ]] \
|| note '.claude/skills/panama is not a compatibility symlink to the agent-neutral source'
[[ "$(readlink -f "$repo_dir/.claude/skills/panama" 2>/dev/null)" == \
"$(readlink -f "$repo_dir/.agents/skills/panama" 2>/dev/null)" ]] \
|| note '.claude and .agents resolve the project panama skill differently'
# ── 1. Each skill loads ───────────────────────────────────────────────────── # ── 1. Each skill loads ─────────────────────────────────────────────────────
@@ -66,7 +54,7 @@ for relative in "${SKILL_DIRS[@]}"; do
present+=("$directory") present+=("$directory")
# Frontmatter is the first --- delimited block, and a skill without one is # Frontmatter is the first --- delimited block, and a skill without one is
# not a skill: agent loaders skip the directory entirely. # not a skill: Claude Code skips the directory entirely.
frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")" frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
if [[ -z "$frontmatter" ]]; then if [[ -z "$frontmatter" ]]; then
note "$relative/SKILL.md does not open with a --- frontmatter block" note "$relative/SKILL.md does not open with a --- frontmatter block"
@@ -219,12 +207,12 @@ else
work="$(mktemp -d)" work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
# A checkout and a home of its own. Never the real ones: these are somebody's # A checkout and a home of its own. Never the real ones: ~/.claude/skills on
# live agent setup, and a contract that broke them mid-session would be worse # this machine is somebody's live agent setup, and a contract that broke it
# than the bug it was looking for. # mid-session would be worse than the bug it was looking for.
checkout="$work/Panama" checkout="$work/Panama"
home="$work/home" home="$work/home"
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents" mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude"
cp "$linker" "$checkout/setup/scripts/link-skills" cp "$linker" "$checkout/setup/scripts/link-skills"
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md" printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
@@ -232,7 +220,6 @@ else
# symlink, which is what link-user used to leave here. # symlink, which is what link-user used to leave here.
mkdir -p "$work/personal" mkdir -p "$work/personal"
ln -s "$work/personal" "$home/.claude/skills" ln -s "$work/personal" "$home/.claude/skills"
ln -s "$work/personal" "$home/.agents/skills"
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; } run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
@@ -244,30 +231,22 @@ else
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it' || note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
[[ -L "$home/.claude/skills/shipped" ]] \ [[ -L "$home/.claude/skills/shipped" ]] \
|| note 'link-skills did not link each shipped skill as a child of ~/.claude/skills' || note 'link-skills did not link each shipped skill as a child of ~/.claude/skills'
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|| note 'link-skills left ~/.agents/skills a symlink, so shipped and personal skills cannot coexist'
[[ -L "$home/.agents/skills/shipped" ]] \
|| note 'link-skills did not link each shipped skill as a child of ~/.agents/skills'
grep -q 'Agent skills: 1 linked' "$work/log" \ grep -q 'Agent skills: 1 linked' "$work/log" \
|| note 'link-skills does not report how many skills it linked' || note 'link-skills does not report how many skills it linked'
# A real directory at a shipped skill's name is somebody's work: it moves to # A real directory at a shipped skill's name is somebody's work: it moves to
# config/old rather than being deleted, the same promise the other stages # config/old rather than being deleted, the same promise the other stages
# make. A symlink is not, and must not accumulate there. # make. A symlink is not, and must not accumulate there.
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do rm "$home/.claude/skills/shipped"
rm -f "$skill_home/shipped" mkdir -p "$home/.claude/skills/shipped"
mkdir -p "$skill_home/shipped" printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md"
printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md" mkdir -p "$home/.claude/skills/untouched"
mkdir -p "$skill_home/untouched"
done
run run
[[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \ grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \
|| note 'link-skills did not preserve real skills from both agent homes' || note 'link-skills destroyed a real skill instead of moving it to config/old'
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do [[ -d "$home/.claude/skills/untouched" ]] \
[[ -d "$skill_home/untouched" ]] \ || note 'link-skills removed a skill it does not ship'
|| note "link-skills removed an unshipped skill from $skill_home"
done
before="$(find "$checkout/config/old" | wc -l)" before="$(find "$checkout/config/old" | wc -l)"
run run
@@ -308,8 +287,8 @@ fi
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \ grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \
|| note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory' || note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory'
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \ grep -qE '^\s*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|| note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills' || note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path'
grep -q 'linkdir)' "$user_linker" \ grep -q 'linkdir)' "$user_linker" \
|| note 'link-user does not implement the linkdir kind the manifest asks for' || note 'link-user does not implement the linkdir kind the manifest asks for'
grep -q 'linkdir' "$repo_dir/user/README.md" \ grep -q 'linkdir' "$repo_dir/user/README.md" \
+186 -6
View File
@@ -51,7 +51,9 @@ copy_hash_inputs() {
find "$repo_dir/setup/provenance" -type f -print0 find "$repo_dir/setup/provenance" -type f -print0
) )
mkdir -p "$root/setup/lib" mkdir -p "$root/setup/lib"
cp -- "$repo_dir/setup/lib/artifact-provenance" "$root/setup/lib/artifact-provenance" cp -- "$repo_dir/setup/lib/artifact-provenance" \
"$repo_dir/setup/lib/extras-catalog" \
"$repo_dir/setup/lib/machine-role" "$root/setup/lib/"
} }
# A PANAMA_PATH that looks enough like the real one for install to run, and # A PANAMA_PATH that looks enough like the real one for install to run, and
@@ -61,7 +63,7 @@ build_fixture() {
rm -rf "$root" rm -rf "$root"
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \ mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
"$root/setup/lib" "$root/setup/provenance/keys" \ "$root/setup/lib" "$root/setup/provenance/keys" \
"$root/config/dot/quickshell/scripts" "$root/config/dot/quickshell/scripts" "$root/tmp"
cp "$installer" "$root/install" cp "$installer" "$root/install"
: >"$root/bin/ascii" : >"$root/bin/ascii"
@@ -126,6 +128,46 @@ EOF
#!/usr/bin/env bash #!/usr/bin/env bash
printf 'dnf-transaction\n' >>"$PANAMA_RAN" printf 'dnf-transaction\n' >>"$PANAMA_RAN"
exit 0 exit 0
EOF
cat >"$root/shim/mv" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
destination="${!#}"
if [[ "${STUB_SIGNAL_PACKAGES_HASH:-0}" == 1 \
&& "$destination" == */state/panama/packages-hash ]]; then
printf 'signal:packages-receipt\n' >>"$PANAMA_RAN"
pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
kill -TERM -- "-$pgid"
sleep 2
fi
exec /usr/bin/mv "$@"
EOF
cat >"$root/shim/mktemp" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${STUB_SIGNAL_HASH_WORK:-0}" == 1 && "${1:-}" == -d ]]; then
directory="$(/usr/bin/mktemp "$@")"
printf '%s\n' "$directory"
printf 'signal:packages-hash-work\n' >>"$PANAMA_RAN"
pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
kill -TERM -- "-$pgid"
sleep 2
fi
exec /usr/bin/mktemp "$@"
EOF
cat >"$root/shim/mkdir" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
target="${!#}"
if [[ "${STUB_SIGNAL_HASH_WORK:-0}" == 1 \
&& "$(basename -- "$target")" == panama-packages-hash.* ]]; then
/usr/bin/mkdir "$@"
printf 'signal:packages-hash-work\n' >>"$PANAMA_RAN"
pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
kill -TERM -- "-$pgid"
sleep 2
fi
exec /usr/bin/mkdir "$@"
EOF EOF
for prerequisite in gum lspci mokutil fwupdmgr; do for prerequisite in gum lspci mokutil fwupdmgr; do
ln -s gsettings "$root/shim/$prerequisite" ln -s gsettings "$root/shim/$prerequisite"
@@ -139,7 +181,8 @@ run_install() {
local status=0 local status=0
: >"$root/ran" : >"$root/ran"
PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \ PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \
XDG_STATE_HOME="$root/state" bash "$root/install" "$@" \ XDG_STATE_HOME="$root/state" TMPDIR="$root/tmp" \
/usr/bin/setsid bash "$root/install" "$@" \
>"$root/out" 2>&1 || status=$? >"$root/out" 2>&1 || status=$?
cat "$root/ran" cat "$root/ran"
return "$status" return "$status"
@@ -147,7 +190,8 @@ run_install() {
run_hash() { run_hash() {
local root="$1" local root="$1"
sed -n '/^hash_packages() {/,/^}$/p' "$root/install" >"$root/hash-only" sed -n '/^_collect_package_inputs() {/,/^PACKAGE_START_HASH=/p' \
"$root/install" >"$root/hash-only"
printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only" printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only"
PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err" PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err"
} }
@@ -255,7 +299,8 @@ grep -qx 'install-packages' <<<"$ran_forced" \
# Dynamically discovering them makes this fail when a new reviewed input is # Dynamically discovering them makes this fail when a new reviewed input is
# added but omitted from hash_packages. # added but omitted from hash_packages.
for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \ for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \
'setup/scripts/install-packages' 'setup/lib/artifact-provenance'; do 'setup/scripts/install-packages' 'setup/lib/artifact-provenance' \
'setup/lib/extras-catalog' 'setup/lib/machine-role'; do
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative" printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
install_status=0 install_status=0
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
@@ -282,7 +327,8 @@ done
# Fixed hash inputs must not silently disappear or degrade into a directory or # Fixed hash inputs must not silently disappear or degrade into a directory or
# link. An unreadable package input also proves a failed content read cannot be # link. An unreadable package input also proves a failed content read cannot be
# hidden by the final digest command. # hidden by the final digest command.
for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance; do for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance \
setup/lib/extras-catalog setup/lib/machine-role; do
for case_name in missing directory symlink unreadable; do for case_name in missing directory symlink unreadable; do
case_root="$tmp/hash-${fixed_input//\//-}-$case_name" case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
build_fixture "$case_root" build_fixture "$case_root"
@@ -305,6 +351,27 @@ build_fixture "$read_failure_root"
chmod 000 "$read_failure_root/${package_inputs[0]}" chmod 000 "$read_failure_root/${package_inputs[0]}"
assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable" assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable"
# Discovery must reject a symlink instead of silently dropping it from the
# receipt while a later consumer follows it.
for discovered_root in setup/packages setup/provenance; do
case_root="$tmp/hash-${discovered_root//\//-}-symlink"
build_fixture "$case_root"
printf 'linked installer input\n' >"$case_root/symlink-target"
ln -s "$case_root/symlink-target" "$case_root/$discovered_root/symlink-input"
assert_hash_failure "$case_root" "$discovered_root symlink input"
done
# Discovery roots are behavior inputs too. GNU find -P treats a symlink passed
# as its starting path as an empty traversal, so checking only descendants can
# silently erase a whole package or provenance tree from the receipt.
for discovered_root in setup/packages setup/provenance; do
case_root="$tmp/hash-${discovered_root//\//-}-root-symlink"
build_fixture "$case_root"
mv -- "$case_root/$discovered_root" "$case_root/$discovered_root.real"
ln -s "$case_root/$discovered_root.real" "$case_root/$discovered_root"
assert_hash_failure "$case_root" "$discovered_root discovery-root symlink"
done
# A hash failure is an installer failure, not a reason to skip the package # A hash failure is an installer failure, not a reason to skip the package
# stage and retain a stale stamp. # stage and retain a stale stamp.
build_fixture "$tmp/hash-failure" build_fixture "$tmp/hash-failure"
@@ -320,6 +387,61 @@ grep -qx 'install-packages' <<<"$ran_hash_failure" \
cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \ cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \
|| note 'a failed package-state hash wrote a new packages-hash stamp' || note 'a failed package-state hash wrote a new packages-hash stamp'
# The stage may race its own input receipt. A successful stage that changes a
# sourced behavior file must not stamp the new digest as though it were the
# bytes used to decide this run.
build_fixture "$tmp/hash-drift"
cat >"$tmp/hash-drift/setup/scripts/install-packages" <<'EOF'
#!/usr/bin/env bash
if [[ "${1:-}" == --trust-preflight ]]; then
printf 'trust-preflight\n' >>"$PANAMA_RAN"
exit 0
fi
printf 'install-packages\n' >>"$PANAMA_RAN"
printf '# changed during package stage\n' >>"$PANAMA_PATH/setup/lib/machine-role"
EOF
chmod +x "$tmp/hash-drift/setup/scripts/install-packages"
install_status=0
run_install "$tmp/hash-drift" --upgrade >/dev/null || install_status=$?
[[ "$install_status" -ne 0 ]] \
|| note 'mid-stage package input drift returned success'
[[ ! -e "$tmp/hash-drift/state/panama/packages-hash" ]] \
|| note 'mid-stage package input drift stamped bytes the stage did not start with'
# The hash workspace exists before command substitution publishes its pathname.
# A process-group signal in that window must still remove the private tree.
build_fixture "$tmp/hash-work-signal"
install_status=0
signal_run="$(STUB_SIGNAL_HASH_WORK=1 \
run_install "$tmp/hash-work-signal" --upgrade)" || install_status=$?
[[ "$install_status" -eq 143 ]] \
|| note "package hash workspace signal returned $install_status instead of 143"
grep -qx 'signal:packages-hash-work' <<<"$signal_run" \
|| note 'package hash workspace adapter did not deliver a real process-group signal'
[[ -z "$(find "$tmp/hash-work-signal/tmp" -mindepth 1 -print -quit)" ]] \
|| note 'package hash workspace signal left a private temporary directory'
# A real process-group signal at the final receipt rename must preserve the
# prior stamp and remove the private temporary receipt.
build_fixture "$tmp/hash-receipt-signal"
run_install "$tmp/hash-receipt-signal" --upgrade >/dev/null
cp -- "$tmp/hash-receipt-signal/state/panama/packages-hash" \
"$tmp/hash-receipt-signal/stamp-before"
install_status=0
signal_run="$(STUB_SIGNAL_PACKAGES_HASH=1 \
run_install "$tmp/hash-receipt-signal" --upgrade --packages)" \
|| install_status=$?
[[ "$install_status" -eq 143 ]] \
|| note "package receipt signal returned $install_status instead of 143"
grep -qx 'signal:packages-receipt' <<<"$signal_run" \
|| note 'package receipt signal adapter did not deliver a real process-group signal'
cmp -s -- "$tmp/hash-receipt-signal/stamp-before" \
"$tmp/hash-receipt-signal/state/panama/packages-hash" \
|| note 'package receipt signal replaced the prior hash stamp'
[[ -z "$(find "$tmp/hash-receipt-signal/state/panama" \
-name '.packages-hash.*' -print -quit)" ]] \
|| note 'package receipt signal left a temporary hash stamp'
# A failing stage must not record the hash, or the failure is hidden forever. # A failing stage must not record the hash, or the failure is hidden forever.
build_fixture "$tmp/c" 1 build_fixture "$tmp/c" 1
install_status=0 install_status=0
@@ -368,6 +490,64 @@ for suppressed in link-dotfiles link-skills link-user change-settings install-ha
&& note "stage-time Terra trust failure still ran $suppressed" && note "stage-time Terra trust failure still ran $suppressed"
done done
# Exercise the complete real package entrypoint at the second boundary. The
# outer preflight sees no Terra repository; the same DNF adapter exposes an
# unsafe enabled Terra identity to the package stage's own preflight. Removing
# that production call would reach the transaction marker below.
real_preflight_root="$tmp/real-second-preflight"
build_fixture "$real_preflight_root"
cp -- "$repo_dir/setup/scripts/install-packages" \
"$real_preflight_root/setup/scripts/install-packages"
chmod +x "$real_preflight_root/setup/scripts/install-packages"
mkdir -p "$real_preflight_root/state/panama"
printf 'server\n' >"$real_preflight_root/state/panama/role"
cat >"$real_preflight_root/shim/dnf" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == '--quiet --no-plugins --dump-repo-config=*' ]]; then
count=0
[[ ! -f "$PANAMA_DNF_DUMP_COUNT" ]] || read -r count <"$PANAMA_DNF_DUMP_COUNT"
count=$((count + 1))
printf '%s\n' "$count" >"$PANAMA_DNF_DUMP_COUNT"
printf 'dnf-dump\n' >>"$PANAMA_RAN"
printf '======== "fedora" repository configuration: ========\n'
printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n'
printf 'gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-44-primary\n'
printf 'metalink = https://mirrors.fedoraproject.org/metalink\nmirrorlist\n'
printf 'pkg_gpgcheck = 0\nrepo_gpgcheck = 0\n'
if (( count == 2 )); then
printf '======== "terra" repository configuration: ========\n'
printf 'baseurl = https://evil.invalid/terra44\nenabled = 1\ngpgcheck = 0\n'
printf 'gpgkey = https://evil.invalid/key\n'
printf 'metalink = \nmirrorlist = \npkg_gpgcheck = 0\nrepo_gpgcheck = 0\n'
fi
exit 0
fi
printf 'dnf-transaction\n' >>"$PANAMA_RAN"
exit 0
EOF
chmod +x "$real_preflight_root/shim/dnf"
printf '0\n' >"$real_preflight_root/dnf-dump-count"
: >"$real_preflight_root/ran"
real_preflight_status=0
PATH="$real_preflight_root/shim:$PATH" \
PANAMA_PATH="$real_preflight_root" PANAMA_RAN="$real_preflight_root/ran" \
PANAMA_DNF_DUMP_COUNT="$real_preflight_root/dnf-dump-count" \
XDG_STATE_HOME="$real_preflight_root/state" \
bash "$real_preflight_root/install" --upgrade --packages \
>"$real_preflight_root/out" 2>&1 || real_preflight_status=$?
[[ "$real_preflight_status" -eq 78 ]] \
|| note "real second repository preflight returned $real_preflight_status instead of 78"
[[ "$(<"$real_preflight_root/dnf-dump-count")" == 2 ]] \
|| note "real package entrypoint executed $(<"$real_preflight_root/dnf-dump-count") repository preflights instead of two: $(tr '\n' ' ' <"$real_preflight_root/out")"
[[ "$(grep -c '^dnf-dump$' "$real_preflight_root/ran")" -eq 2 ]] \
|| note "real second preflight fixture log was: $(tr '\n' ',' <"$real_preflight_root/ran")"
for suppressed in dnf-transaction link-dotfiles link-skills link-user change-settings \
install-hardware; do
grep -qx "$suppressed" "$real_preflight_root/ran" \
&& note "real second repository preflight still ran $suppressed"
done
# A full install always runs the stage, whatever any recorded hash says. # A full install always runs the stage, whatever any recorded hash says.
build_fixture "$tmp/d" build_fixture "$tmp/d"
install_status=0 install_status=0
+7 -12
View File
@@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE'
# a comment, and a blank line follow # a comment, and a blank line follow
link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/AGENTS.md ~/.claude/CLAUDE.md
linkdir agents/skills ~/.agents/skills link agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills linkdir agents/skills ~/.claude/skills
copy plain.txt ~/.config/plain.txt copy plain.txt ~/.config/plain.txt
link missing.txt ~/.config/missing.txt link missing.txt ~/.config/missing.txt
@@ -102,12 +102,11 @@ PANAMA_USER_CONTENT=no run
# ── 2. Saying yes links, and keeps what was there ─────────────────────────── # ── 2. Saying yes links, and keeps what was there ───────────────────────────
# Both skill homes are shared now: link-skills has already made them real # ~/.claude/skills is shared now: link-skills has already made it a real
# directories and linked Panama's own skills into each. Personal linkdir # directory and linked Panama's own skills into it. A linkdir entry has to land
# entries land beside those rather than replacing either directory. # beside those rather than replace the directory holding them.
mkdir -p "$home/.claude/skills" "$home/.agents/skills" mkdir -p "$home/.claude/skills"
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped" ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped"
PANAMA_USER_CONTENT=yes run PANAMA_USER_CONTENT=yes run
@@ -115,12 +114,8 @@ PANAMA_USER_CONTENT=yes run
|| note 'CLAUDE.md was not replaced with a symlink into the checkout' || note 'CLAUDE.md was not replaced with a symlink into the checkout'
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \ [[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|| note 'the CLAUDE.md link does not resolve to the tracked file' || note 'the CLAUDE.md link does not resolve to the tracked file'
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \ [[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|| note 'a linkdir entry replaced ~/.agents/skills with a symlink' || note 'the skills directory was not linked as a directory'
[[ -L "$home/.agents/skills/example" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|| note 'a linkdir entry did not publish the personal skill for agent-neutral readers'
[[ -L "$home/.agents/skills/shipped" ]] \
|| note 'a linkdir entry removed the shipped skill from ~/.agents/skills'
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \ [[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|| note 'a linkdir entry replaced its destination directory with a symlink' || note 'a linkdir entry replaced its destination directory with a symlink'
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \ [[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
+11 -11
View File
@@ -13,7 +13,7 @@ and one file says where each piece goes.
| Path | Goes to | Why | | Path | Goes to | Why |
| --- | --- | --- | | --- | --- | --- |
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. | | `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. |
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. | | `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | A skill installed on any machine lands in the checkout. The Claude path is `linkdir` — see below. |
| `agents/rules/` | `~/.claude/rules` | | | `agents/rules/` | `~/.claude/rules` | |
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. | | `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. | | `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
@@ -27,22 +27,22 @@ destination is empty. `linkdir` is the third, and it exists because one
destination is no longer only ours. destination is no longer only ours.
Panama ships its own agent skills now (`skills/`, linked by Panama ships its own agent skills now (`skills/`, linked by
`setup/scripts/link-skills`), and they go to both skill homes. A directory `setup/scripts/link-skills`), and they go to `~/.claude/skills` — the same
cannot be a symlink to two places, so both personal entries use `linkdir`: each directory the personal ones went to as a single symlink. A directory cannot be
destination is a real directory, and each child of `user/agents/skills/` is a symlink to two places, so that entry became `linkdir`: the destination is a
linked into it individually. real directory, and each child of `user/agents/skills/` is linked into it
individually. `~/.agents/skills` is still a whole-directory `link`, because
nothing else claims it.
Two consequences, recorded rather than fixed: Two consequences, recorded rather than fixed:
- **A personal skill named like a shipped one shadows it.** `link-user` runs - **A personal skill named like a shipped one shadows it.** `link-user` runs
after `link-skills` and displaces what it finds, so the personal one wins in after `link-skills` and displaces what it finds, so the personal one wins.
both skill homes. That is the intent, and it is the precedence Claude Code uses anyway.
- **A new personal skill needs a re-link to appear.** The whole-directory link - **A new personal skill needs a re-link to appear.** The whole-directory link
showed a newly created skill instantly; per-child links do not know about a showed a newly created skill instantly; per-child links do not know about a
child that did not exist when they were made. Put new shared skills in child that did not exist when they were made. Run `panama update` or
`user/agents/skills/`, then run `panama update` or `setup/scripts/link-user`. `setup/scripts/link-user` after adding one.
A tool that installs directly into a home skill directory creates a
machine-local skill until it is moved into the checkout.
## It is off unless you say yes ## It is off unless you say yes
+11 -25
View File
@@ -1,6 +1,6 @@
--- ---
name: ticket name: ticket
description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, drive the pre-mr-review audit to convergence (the user runs it themselves from their work Claude account), and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket. description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, run pre-mr-review to convergence, and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
disable-model-invocation: true disable-model-invocation: true
--- ---
@@ -372,7 +372,7 @@ place from the start.
- Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it) - Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it)
- Resume implementation (some plan steps are already checked off / some commits - Resume implementation (some plan steps are already checked off / some commits
already exist on the ticket branch — pick up from the first unchecked step) already exist on the ticket branch — pick up from the first unchecked step)
- Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc; the user runs the audit from their work account, see Phase 2 step 7) - Run `pre-mr-review` now (implementation looks done, just need the audit + MR doc)
Route to **Phase 1** or **Phase 2** accordingly. Route to **Phase 1** or **Phase 2** accordingly.
- **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user - **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user
`mr.md` already exists at its path and ask whether they want you to refresh it `mr.md` already exists at its path and ask whether they want you to refresh it
@@ -620,8 +620,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
Order matters and is not just convenience. `pre-mr-review`'s output is a readiness Order matters and is not just convenience. `pre-mr-review`'s output is a readiness
verdict that gets pasted verbatim into `mr.md`. Anything that runs after it verdict that gets pasted verbatim into `mr.md`. Anything that runs after it
invalidates that verdict by construction, and you end up rerunning it and rewriting invalidates that verdict by construction, and you end up rerunning it and rewriting
the handoff. Review first, fix, commit, and only then have the user the handoff. Review first, fix, commit, and only then run `pre-mr-review` over the
run `pre-mr-review` over the final tree. final tree.
- **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a - **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a
one line correction with no logic in it. Run it whenever the change adds or one line correction with no logic in it. Run it whenever the change adds or
@@ -671,25 +671,11 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
in the current repo). If it doesn't, stop here, tell the user implementation and in the current repo). If it doesn't, stop here, tell the user implementation and
local verification are done but this repo has no `pre-mr-review` skill to run, and local verification are done but this repo has no `pre-mr-review` skill to run, and
let them decide how to proceed. let them decide how to proceed.
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this 7. Invoke the `pre-mr-review` skill. Read its verdict.
review run from the developer's separate work Claude account, so the audit on
record must come from there, not from this session. Instead:
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
`/pre-mr-review` from their work account, then let you know when it has
finished. Stop and wait, this is a hard gate.
2. When they say it ran, read the audit and handoff files it wrote under
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
skill prints the exact paths). Check the `last_reviewed_head` in the audit
header matches the current HEAD; if the branch moved after their run, say so
and ask them to rerun before acting on a stale audit.
3. Read the verdict.
- **Ready to Open MR**: continue to step 8. - **Ready to Open MR**: continue to step 8.
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix - **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its
as its own commit), then ask the user to rerun the review from the work own commit), then invoke `pre-mr-review` again. Repeat until the verdict is Ready
account and wait again. Repeat until the verdict is Ready to Open MR. Don't to Open MR. Don't write `mr.md` before that verdict is reached.
write `mr.md` before that verdict is reached.
Findings that need no code change (mentions, disclosures) get addressed in
`mr.md`'s Additional Notes or the Jira fields as usual.
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template, 8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
copied into this skill so it still works even though the original copied into this skill so it still works even though the original
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md` `.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
@@ -721,9 +707,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
the audit's own words, not a retelling of them. Concretely: the audit's own words, not a retelling of them. Concretely:
- Never hand-write a handoff section, and never edit one in place. If it is - Never hand-write a handoff section, and never edit one in place. If it is
wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work wrong, thin, or stale, fix the handoff file by rerunning `/pre-mr-review`,
account to regenerate the handoff file, then re-paste. then re-paste.
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole - On EVERY rerun, replace the whole existing handoff block with the whole
regenerated one. Do not patch the copy sitting in `mr.md` to match the new regenerated one. Do not patch the copy sitting in `mr.md` to match the new
head — that is how the two silently diverge, and the version the reviewer head — that is how the two silently diverge, and the version the reviewer
reads stops being the version the audit actually produced. reads stops being the version the audit actually produced.
@@ -16,9 +16,7 @@ ln -s ../../.agents/skills/<name> ~/.claude/skills/<name>
Writing a second copy into `~/.claude/skills/` gives you two files that drift, and the drift is Writing a second copy into `~/.claude/skills/` gives you two files that drift, and the drift is
silent because each harness only ever reads its own. One home, one symlink per harness that needs it. silent because each harness only ever reads its own. One home, one symlink per harness that needs it.
A skill that only makes sense inside one repo lives once at A skill that only makes sense inside one repo belongs in that repo, at `<repo>/.claude/skills/`.
`<repo>/.agents/skills/<name>/`. Point Claude Code at that source with a
`<repo>/.claude/skills/<name>` symlink.
## Frontmatter ## Frontmatter
+7 -6
View File
@@ -28,13 +28,14 @@
link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/AGENTS.md ~/.claude/CLAUDE.md
link agents/AGENTS.md ~/.codex/AGENTS.md link agents/AGENTS.md ~/.codex/AGENTS.md
# Skills, at both paths that look for them. Each tracked skill is linked from # Skills, at both paths that look for them. Linked so that a skill installed by
# this checkout so edits stay shared and `panama update` offers to commit them. # any tool lands in the checkout and `panama update` offers to commit it.
# #
# Both destinations also hold skills Panama itself ships, put there by the # ~/.agents/skills is this directory and nothing else, so it stays one link.
# link-skills stage, so both are real directories with one link per skill. This # ~/.claude/skills also holds the skills Panama itself ships, put there by the
# stage runs after that one, so a personal skill wins a name collision. # link-skills stage, so it is a real directory with one link per skill -- and
linkdir agents/skills ~/.agents/skills # this stage runs after that one, so a personal skill wins a name collision.
link agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills linkdir agents/skills ~/.claude/skills
link agents/rules ~/.claude/rules link agents/rules ~/.claude/rules