Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a69cacc006 |
@@ -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.
|
||||||
@@ -12,10 +12,6 @@
|
|||||||
/config/dot/espanso/match/identity.yml
|
/config/dot/espanso/match/identity.yml
|
||||||
# Ignore backups of old config files
|
# Ignore backups of old config files
|
||||||
/config/old
|
/config/old
|
||||||
# MCP bearer tokens. The server list in user/agents/mcp/servers is tracked
|
|
||||||
# and names which variable carries each token; the tokens themselves are
|
|
||||||
# machine-local, because this repo is public.
|
|
||||||
/user/agents/mcp/env
|
|
||||||
# Ignore Wireguard config of course!
|
# Ignore Wireguard config of course!
|
||||||
/config/wg/**
|
/config/wg/**
|
||||||
# Ignore Neovim lazy-lock file
|
# Ignore Neovim lazy-lock 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
|
||||||
|
|
||||||
190 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
@@ -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=()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+2
-3
@@ -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
|
||||||
@@ -37,9 +36,9 @@ alias fprint-on='sudo authselect enable-feature with-fingerprint'
|
|||||||
alias fprint-off='sudo authselect disable-feature with-fingerprint'
|
alias fprint-off='sudo authselect disable-feature with-fingerprint'
|
||||||
|
|
||||||
# File system
|
# File system
|
||||||
alias ls='eza -lh --group-directories-first --icons=auto'
|
alias ls='eza -lh --group-directories-first --icons'
|
||||||
alias lsa='ls -a'
|
alias lsa='ls -a'
|
||||||
alias lt='eza --tree --level=2 --long --icons=auto --git'
|
alias lt='eza --tree --level=2 --long --icons --git'
|
||||||
alias lta='lt -a'
|
alias lta='lt -a'
|
||||||
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
||||||
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
||||||
|
|||||||
@@ -244,20 +244,10 @@ local shipped_mode = "4500x3000@60"
|
|||||||
local shipped_scale = 1.5
|
local shipped_scale = 1.5
|
||||||
local shipped_transform = 0
|
local shipped_transform = 0
|
||||||
|
|
||||||
-- 8-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
||||||
-- HBR3 and reliant on DSC, and this panel's link is marginal: every modeset
|
-- HBR3, so this relies on DSC. If the display fails to light up or falls back to
|
||||||
-- retrains it and blanks the screen. 8bpc keeps headroom on the link.
|
-- a lower mode, drop this to 8 first.
|
||||||
--
|
local shipped_bitdepth = 10
|
||||||
-- Related: directScanoutPolicy is 0 in Panama settings (2026-09-13). With
|
|
||||||
-- scanout on, a fullscreen game whose buffer depth differs from the desktop
|
|
||||||
-- (games ship both 8- and 10-bit swapchains) makes Hyprland change the output
|
|
||||||
-- format, and on amdgpu a format change is a full modeset. Compositing always
|
|
||||||
-- keeps the format fixed, so the link never retrains mid-game.
|
|
||||||
--
|
|
||||||
-- vrrPolicy is also 0 there. VRR on this panel loses sync and blacks out
|
|
||||||
-- (seen on GNOME in July 2026 and again here); a 60Hz panel gains little
|
|
||||||
-- from it anyway.
|
|
||||||
local shipped_bitdepth = 8
|
|
||||||
|
|
||||||
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
||||||
local shipped_cm = "auto"
|
local shipped_cm = "auto"
|
||||||
|
|||||||
@@ -125,16 +125,6 @@ hl.window_rule({
|
|||||||
no_dim = true,
|
no_dim = true,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Steam games never get true fullscreen. Everything Hyprland does only for a
|
|
||||||
-- real fullscreen window (direct scanout, VRR, tearing, auto HDR) makes this
|
|
||||||
-- panel's marginal DP link retrain and blank (2026-09-13). "1 2" keeps the
|
|
||||||
-- window maximized internally while the game believes it is fullscreen, which
|
|
||||||
-- is what borderless windowed looks like from the game's side.
|
|
||||||
hl.window_rule({
|
|
||||||
match = { class = "^steam_app_\\d+$" },
|
|
||||||
fullscreen_state = "1 2",
|
|
||||||
})
|
|
||||||
|
|
||||||
-- Steam itself is a normal window, but its transient popups are a mess.
|
-- Steam itself is a normal window, but its transient popups are a mess.
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
|
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
|
||||||
@@ -279,28 +269,16 @@ hl.layer_rule({
|
|||||||
ignore_alpha = 0.2,
|
ignore_alpha = 0.2,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Overview and local screen-reading UI dim the desktop behind them.
|
-- Overview, capture and local screen-reading UI dim the desktop behind them.
|
||||||
hl.layer_rule({
|
hl.layer_rule({
|
||||||
name = "qs-overlay",
|
name = "qs-overlay",
|
||||||
match = { namespace = "^qs-(overview|screen-intelligence)$" },
|
match = { namespace = "^qs-(overview|capture|screen-intelligence)$" },
|
||||||
blur = true,
|
blur = true,
|
||||||
ignore_alpha = 0.4,
|
ignore_alpha = 0.4,
|
||||||
dim_around = true,
|
dim_around = true,
|
||||||
no_screen_share = true,
|
no_screen_share = true,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- The screenshot picker paints an opaque frozen frame edge to edge, so blur and
|
|
||||||
-- dim would be compositor work on pixels nobody sees. no_anim is load-bearing:
|
|
||||||
-- services/Capture.qml unmaps the picker and runs grim 90ms later, and the
|
|
||||||
-- 200ms layersOut fade would otherwise put the half-faded picker, dimming and
|
|
||||||
-- all, into every screenshot.
|
|
||||||
hl.layer_rule({
|
|
||||||
name = "qs-capture",
|
|
||||||
match = { namespace = "^qs-capture$" },
|
|
||||||
no_anim = true,
|
|
||||||
no_screen_share = true,
|
|
||||||
})
|
|
||||||
|
|
||||||
-- Notification toasts. Blurred like every other shell surface -- without this
|
-- Notification toasts. Blurred like every other shell surface -- without this
|
||||||
-- the cards are a near-transparent fill sitting directly on the wallpaper and
|
-- the cards are a near-transparent fill sitting directly on the wallpaper and
|
||||||
-- read as washed out rather than as glass.
|
-- read as washed out rather than as glass.
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,13 +27,9 @@ PanelWindow {
|
|||||||
right: true
|
right: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore, and nothing else: reserve no space (or every window on the
|
// Fullscreen overlays must not reserve space, or every window on the
|
||||||
// workspace resizes as the picker opens and closes) and respect nobody's
|
// workspace gets resized as the picker opens and closes.
|
||||||
// (or the window starts under the bar, the full-output freeze frame is
|
exclusiveZone: 0
|
||||||
// squeezed into a shorter box, and every selection lands one bar-height
|
|
||||||
// off in the real capture). Never pair this with exclusiveZone; see
|
|
||||||
// tests/quickshell/exclusion-idiom-contract.
|
|
||||||
exclusionMode: ExclusionMode.Ignore
|
|
||||||
|
|
||||||
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
|
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors { top: true; bottom: true; left: true; right: true }
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
|
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ PanelWindow {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
// A dock that reserved space would not be intellihiding.
|
// A dock that reserved space would not be intellihiding.
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
|
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors { top: true; bottom: true; left: true; right: true }
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ PanelWindow {
|
|||||||
// Reserve nothing and respect nothing: the glow is drawn over the whole
|
// Reserve nothing and respect nothing: the glow is drawn over the whole
|
||||||
// output including under the bar and the dock, which is what makes it
|
// output including under the bar and the dock, which is what makes it
|
||||||
// visible from wherever the eyes happen to be.
|
// visible from wherever the eyes happen to be.
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ PanelWindow {
|
|||||||
screen: root.modelData
|
screen: root.modelData
|
||||||
anchors.bottom: true
|
anchors.bottom: true
|
||||||
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
implicitWidth: root.desiredWidth
|
implicitWidth: root.desiredWidth
|
||||||
implicitHeight: 64
|
implicitHeight: 64
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
||||||
|
|||||||
@@ -34,23 +34,11 @@ Item {
|
|||||||
visible: root.rowCount > 0
|
visible: root.rowCount > 0
|
||||||
implicitHeight: rows.implicitHeight
|
implicitHeight: rows.implicitHeight
|
||||||
|
|
||||||
// Probing I2C takes on the order of a second, so it waits until the quick
|
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||||
// settings panel is actually on screen rather than running at shell
|
// is actually on screen rather than running at shell startup. Monitors do
|
||||||
// startup. Monitors do not come and go, so once is enough.
|
// not come and go, so once is enough.
|
||||||
//
|
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||||
// The trigger is the panel opening, not this item becoming visible: on a
|
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||||
// machine with no backlight, `visible` stays false until a scan has found
|
|
||||||
// a DDC display, so keying the scan off it would never scan at all.
|
|
||||||
Connections {
|
|
||||||
target: ShellState
|
|
||||||
function onQuickSettingsOpenChanged(): void { root.scanIfOpen(); }
|
|
||||||
}
|
|
||||||
Component.onCompleted: root.scanIfOpen()
|
|
||||||
|
|
||||||
function scanIfOpen(): void {
|
|
||||||
if (ShellState.quickSettingsOpen && !Brightness.scanned)
|
|
||||||
Brightness.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||||
Process {
|
Process {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Variants {
|
|||||||
implicitWidth: 260
|
implicitWidth: 260
|
||||||
implicitHeight: 172
|
implicitHeight: 172
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
mask: Region {}
|
mask: Region {}
|
||||||
|
|
||||||
|
|||||||
@@ -383,13 +383,6 @@ Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
|
|||||||
0 on syntax and runtime errors too. The only trustworthy signal that a write
|
0 on syntax and runtime errors too. The only trustworthy signal that a write
|
||||||
landed is reading the value back.
|
landed is reading the value back.
|
||||||
|
|
||||||
**`exclusionMode: ExclusionMode.Ignore` goes alone.** Quickshell's `exclusiveZone`
|
|
||||||
setter flips `exclusionMode` back to `Normal` as a side effect, so a window that
|
|
||||||
declares both is at the mercy of which property the QML engine applies last.
|
|
||||||
The 2026-09-14 Qt update changed that order and every full-screen overlay slid
|
|
||||||
under the bar. `tests/quickshell/exclusion-idiom-contract` fails any file that
|
|
||||||
pairs them.
|
|
||||||
|
|
||||||
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
|
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
|
||||||
and it ranges from a half-screen split to the full display. `SliderRow` stacks
|
and it ranges from a half-screen split to the full display. `SliderRow` stacks
|
||||||
its control under the label below 520px. Test narrow.
|
its control under the label below 520px. Test narrow.
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors { top: true; bottom: true; left: true; right: true }
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
exclusiveZone: 0
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
WlrLayershell.namespace: "qs-popover-welcome"
|
WlrLayershell.namespace: "qs-popover-welcome"
|
||||||
|
|||||||
@@ -341,10 +341,7 @@ esac'
|
|||||||
property bool record: false
|
property bool record: false
|
||||||
property bool intelligence: false
|
property bool intelligence: false
|
||||||
// A handful of frames at 60Hz, enough for the compositor to recomposite
|
// A handful of frames at 60Hz, enough for the compositor to recomposite
|
||||||
// the output without the overlay on it. Only enough because the
|
// the output without the overlay on it.
|
||||||
// qs-capture layer rule in hypr/rules.lua sets no_anim: with the
|
|
||||||
// default 200ms layersOut fade the half-faded picker would still be
|
|
||||||
// on screen when grim reads it.
|
|
||||||
interval: 90
|
interval: 90
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
const r = commitDelay.rect;
|
const r = commitDelay.rect;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ source "$PANAMA_PATH/bin/ascii"
|
|||||||
# time. On an upgrade it is worth running only when its package lists or
|
# time. On an upgrade it is worth running only when its package lists or
|
||||||
# reviewed installer trust inputs changed, so this hashes them and remembers
|
# reviewed installer trust inputs changed, so this hashes them and remembers
|
||||||
# the result. The framed, sorted stream includes top-level package files, the
|
# the result. The framed, sorted stream includes top-level package files, the
|
||||||
# package-stage adapter, every helper it sources, and regular provenance files;
|
# package-stage adapter, the provenance helper, and regular provenance files;
|
||||||
# both relative paths and bytes are part of the state.
|
# both relative paths and bytes are part of the state.
|
||||||
#
|
#
|
||||||
# A content hash rather than a git range, because Panama is developed in place:
|
# A content hash rather than a git range, because Panama is developed in place:
|
||||||
@@ -88,42 +88,75 @@ 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" \
|
&& ! -L "$PANAMA_PATH/setup/provenance" ]] || return 1
|
||||||
"$PANAMA_PATH/setup/lib/chatgpt-package" \
|
|
||||||
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
|
||||||
"$PANAMA_PATH/setup/lib/machine-role"; do
|
|
||||||
[[ -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" \
|
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||||
"$PANAMA_PATH/setup/lib/chatgpt-package" \
|
|
||||||
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
||||||
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
|
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
|
||||||
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
|
# extras/ is deliberately excluded. A symlink or other non-directory
|
||||||
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
|
# object at this level is still an input error, not something discovery may
|
||||||
relative="${file#"$PANAMA_PATH"/}"
|
# silently omit.
|
||||||
size="$(wc -c <"$file")" || exit 1
|
find "$PANAMA_PATH/setup/packages" -mindepth 1 -maxdepth 1 \
|
||||||
printf '%s\0%s\0' "$relative" "$size" || exit 1
|
! -type d -print0 || exit 1
|
||||||
cat -- "$file" || exit 1
|
find "$PANAMA_PATH/setup/provenance" -mindepth 1 \
|
||||||
printf '\0' || exit 1
|
! -type d -print0 || exit 1
|
||||||
done | sha256sum | cut -d' ' -f1
|
} >"$raw" || return 1
|
||||||
)" || return 1
|
LC_ALL=C sort -z "$raw" >"$destination"
|
||||||
printf '%s\n' "$digest"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
packages_needed() {
|
_write_package_manifest() {
|
||||||
local current_hash="$1" recorded_hash
|
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() {
|
||||||
|
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
|
||||||
@@ -134,52 +167,41 @@ 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 starting_hash="$1" current_hash 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="$(hash_packages)" || return 1
|
||||||
[[ "$current_hash" == "$starting_hash" ]] || 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 printf '%s\n' "$starting_hash" >"$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. It suppresses every stage that
|
# Status 78 is reserved for a trust-root failure and is propagated unchanged so
|
||||||
# opens DNF -- install-hardware included, which would otherwise pull drivers
|
# no later stage, especially install-hardware, can invoke DNF with that repo.
|
||||||
# through the very repository in doubt -- and the run still exits 78 at the end.
|
|
||||||
#
|
|
||||||
# It suppresses nothing else. Linking dotfiles, skills and user content reads no
|
|
||||||
# repository and installs no package, and a machine whose Terra is in question
|
|
||||||
# still wants its configuration. Refusing the safe work because the unsafe work
|
|
||||||
# is unavailable does not make the machine safer, it just leaves the machine
|
|
||||||
# unconfigured with no way to fix itself. Exiting here instead meant link-skills
|
|
||||||
# never ran on a machine whose Terra was merely unadopted, so ~/.claude/skills
|
|
||||||
# stayed the whole-directory symlink it had been before skills were linked one
|
|
||||||
# by one, and not one shipped skill was reachable.
|
|
||||||
TERRA_TRUST_FAILURE_STATUS=78
|
TERRA_TRUST_FAILURE_STATUS=78
|
||||||
DNF_STAGES=(install-packages change-settings install-hardware)
|
|
||||||
package_trust_refused=0
|
|
||||||
|
|
||||||
stage_opens_dnf() {
|
|
||||||
local candidate="$1" dnf_stage
|
|
||||||
for dnf_stage in "${DNF_STAGES[@]}"; do
|
|
||||||
[[ "$candidate" == "$dnf_stage" ]] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
|
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
|
||||||
if [[ ! -x "$trust_preflight" ]]; then
|
if [[ ! -x "$trust_preflight" ]]; then
|
||||||
printf 'install: package repository trust preflight is unavailable\n' >&2
|
printf 'install: package repository trust preflight is unavailable\n' >&2
|
||||||
package_trust_refused=1
|
exit "$TERRA_TRUST_FAILURE_STATUS"
|
||||||
elif ! "$trust_preflight" --trust-preflight; then
|
fi
|
||||||
|
if "$trust_preflight" --trust-preflight; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
trust_status=$?
|
||||||
printf 'install: package repository trust preflight failed\n' >&2
|
printf 'install: package repository trust preflight failed\n' >&2
|
||||||
package_trust_refused=1
|
exit "$trust_status"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── The interview ────────────────────────────────────────────────────────────
|
# ── The interview ────────────────────────────────────────────────────────────
|
||||||
@@ -199,7 +221,7 @@ fi
|
|||||||
# Gated exactly like the interview itself: under --upgrade no questions are
|
# Gated exactly like the interview itself: under --upgrade no questions are
|
||||||
# asked, so nothing here is used, and a machine that cannot install gum must
|
# asked, so nothing here is used, and a machine that cannot install gum must
|
||||||
# not have that stop an upgrade that never needed it.
|
# not have that stop an upgrade that never needed it.
|
||||||
if (( ! UPGRADE && ! package_trust_refused )); then
|
if (( ! UPGRADE )); then
|
||||||
bootstrap=()
|
bootstrap=()
|
||||||
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
||||||
# The probe tools serve only the hardware questions, which a server is never
|
# The probe tools serve only the hardware questions, which a server is never
|
||||||
@@ -214,7 +236,8 @@ if (( ! UPGRADE && ! package_trust_refused )); 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
|
||||||
}
|
}
|
||||||
@@ -277,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,7 +371,7 @@ fi
|
|||||||
if [[ "$PANAMA_ROLE" == server ]]; then
|
if [[ "$PANAMA_ROLE" == server ]]; then
|
||||||
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
|
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
|
||||||
else
|
else
|
||||||
STAGES=(install-packages link-dotfiles link-skills link-user link-mcp change-settings link-vicinae-scripts setup-identity install-hardware)
|
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The two an upgrade drops. Both exist only to act on interview answers, and
|
# The two an upgrade drops. Both exist only to act on interview answers, and
|
||||||
@@ -366,16 +394,11 @@ for stage in "${STAGES[@]}"; do
|
|||||||
script="$PANAMA_PATH/setup/scripts/$stage"
|
script="$PANAMA_PATH/setup/scripts/$stage"
|
||||||
[[ -x "$script" ]] || continue
|
[[ -x "$script" ]] || continue
|
||||||
printf '\n=== %s ===\n' "$stage"
|
printf '\n=== %s ===\n' "$stage"
|
||||||
if (( package_trust_refused )) && stage_opens_dnf "$stage"; then
|
|
||||||
echo "Skipped: the package repository trust check refused package work."
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ "$stage" == install-packages ]]; then
|
if [[ "$stage" == install-packages ]]; then
|
||||||
|
package_start_hash=""
|
||||||
package_state_status=0
|
package_state_status=0
|
||||||
package_start_hash="$(hash_packages)" || package_state_status=2
|
packages_needed || package_state_status=$?
|
||||||
if (( package_state_status == 0 )); then
|
package_start_hash="$PACKAGE_START_HASH"
|
||||||
packages_needed "$package_start_hash" || package_state_status=$?
|
|
||||||
fi
|
|
||||||
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."
|
||||||
@@ -395,12 +418,9 @@ for stage in "${STAGES[@]}"; do
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
stage_status=$?
|
stage_status=$?
|
||||||
# A configuration change between the preflight and this stage. Suppress the
|
|
||||||
# remaining DNF stages, keep the safe ones, and carry the status to the end.
|
|
||||||
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
|
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
|
||||||
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
|
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
|
||||||
package_trust_refused=1
|
exit "$stage_status"
|
||||||
continue
|
|
||||||
fi
|
fi
|
||||||
failed+=("$stage")
|
failed+=("$stage")
|
||||||
printf '!!! %s failed\n' "$stage" >&2
|
printf '!!! %s failed\n' "$stage" >&2
|
||||||
@@ -426,16 +446,8 @@ done
|
|||||||
# written for -- and baselining would skip every one of them forever. Every
|
# written for -- and baselining would skip every one of them forever. Every
|
||||||
# migration is self-guarding and a no-op where it does not apply, so running
|
# migration is self-guarding and a no-op where it does not apply, so running
|
||||||
# them is the safe direction.
|
# them is the safe direction.
|
||||||
#
|
|
||||||
# Held back when package work was refused. A migration is free to run a DNF
|
|
||||||
# transaction -- the ChatGPT package replacement does exactly that -- so the
|
|
||||||
# repositories have to be trustworthy before any of them is allowed to run.
|
|
||||||
# They are not marked applied either, so the next run still has them pending.
|
|
||||||
migrate="$PANAMA_PATH/bin/panama-migrate"
|
migrate="$PANAMA_PATH/bin/panama-migrate"
|
||||||
if (( package_trust_refused )) && [[ -x "$migrate" ]]; then
|
if [[ -x "$migrate" ]]; then
|
||||||
printf '\n=== migrations ===\n'
|
|
||||||
echo "Skipped: the package repository trust check refused package work."
|
|
||||||
elif [[ -x "$migrate" ]]; then
|
|
||||||
printf '\n=== migrations ===\n'
|
printf '\n=== migrations ===\n'
|
||||||
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
|
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
|
||||||
"$migrate" run || failed+=(migrations)
|
"$migrate" run || failed+=(migrations)
|
||||||
@@ -483,17 +495,6 @@ else
|
|||||||
retry='./install'
|
retry='./install'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reported last and on its own, because it is not an ordinary stage failure:
|
|
||||||
# everything safe did run, and what did not run is named rather than buried in a
|
|
||||||
# list. The exit status stays 78 so a caller can still tell the two apart.
|
|
||||||
if (( package_trust_refused )); then
|
|
||||||
printf 'Package work was refused: the Terra repository configuration on this\n' >&2
|
|
||||||
printf 'machine is not one Panama can verify. Skipped: %s\n' "${DNF_STAGES[*]}" >&2
|
|
||||||
printf 'Everything that touches no repository was still applied.\n' >&2
|
|
||||||
printf 'Inspect it with: panama diagnose\n' >&2
|
|
||||||
exit "$TERRA_TRUST_FAILURE_STATUS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (( ${#failed[@]} == 0 )); then
|
if (( ${#failed[@]} == 0 )); then
|
||||||
if (( UPGRADE )); then
|
if (( UPGRADE )); then
|
||||||
echo "Panama is up to date."
|
echo "Panama is up to date."
|
||||||
|
|||||||
@@ -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."
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }'
|
|
||||||
```
|
|
||||||
@@ -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-----
|
|
||||||
@@ -7,20 +7,23 @@
|
|||||||
declare -gA INSTALLER_PROVENANCE=()
|
declare -gA INSTALLER_PROVENANCE=()
|
||||||
|
|
||||||
_primary_key_fingerprints() (
|
_primary_key_fingerprints() (
|
||||||
local home
|
local home="" gpg_output
|
||||||
set -o pipefail
|
trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT
|
||||||
home="$(mktemp -d)" || exit 1
|
|
||||||
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 output
|
|
||||||
local -a primary_fingerprints=()
|
local -a primary_fingerprints=()
|
||||||
output="$(_primary_key_fingerprints "$file")" || return 1
|
output="$(_primary_key_fingerprints "$file")" || return 1
|
||||||
[[ -n "$output" ]] || return 1
|
[[ -n "$output" ]] || return 1
|
||||||
@@ -41,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
|
||||||
)
|
)
|
||||||
@@ -67,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
|
||||||
@@ -78,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
|
||||||
@@ -112,7 +123,6 @@ load_installer_provenance() {
|
|||||||
RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \
|
RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \
|
||||||
FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \
|
FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \
|
||||||
RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \
|
RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \
|
||||||
TERRA_METALINK_BASEURL \
|
|
||||||
HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \
|
HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \
|
||||||
CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \
|
CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \
|
||||||
BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \
|
BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \
|
||||||
|
|||||||
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ RPMFUSION_FREE_RELEASE_MAX_BYTES=4194304
|
|||||||
RPMFUSION_NONFREE_RELEASE_URL=https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm
|
RPMFUSION_NONFREE_RELEASE_URL=https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm
|
||||||
RPMFUSION_NONFREE_RELEASE_MAX_BYTES=4194304
|
RPMFUSION_NONFREE_RELEASE_MAX_BYTES=4194304
|
||||||
TERRA_BASEURL=https://repos.fyralabs.com/terra44
|
TERRA_BASEURL=https://repos.fyralabs.com/terra44
|
||||||
TERRA_METALINK_BASEURL=https://tetsudou.fyralabs.com/metalink
|
|
||||||
HYPRLAND_COPR_BASEURL=https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/
|
HYPRLAND_COPR_BASEURL=https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/
|
||||||
FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo
|
FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo
|
||||||
FLATHUB_DESCRIPTOR_MAX_BYTES=1048576
|
FLATHUB_DESCRIPTOR_MAX_BYTES=1048576
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+706
-347
File diff suppressed because it is too large
Load Diff
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# MCP servers, registered with the agent runtimes on this machine.
|
|
||||||
#
|
|
||||||
# An MCP server is a URL plus a bearer token, and the token is the whole reason
|
|
||||||
# this is a stage rather than a manifest line. Panama is a public repository, so
|
|
||||||
# the tokens cannot live in it, and neither runtime keeps its server list in a
|
|
||||||
# file that could be symlinked anyway: Codex writes them into config.toml beside
|
|
||||||
# dozens of unrelated settings, and Claude Code into ~/.claude.json. There is no
|
|
||||||
# file to point at, so this registers them through the runtime's own CLI.
|
|
||||||
#
|
|
||||||
# What is tracked is user/agents/mcp/servers, which names each server and which
|
|
||||||
# variable carries its token. What is not tracked is user/agents/mcp/env, which
|
|
||||||
# holds the tokens. A new machine gets the servers by dropping its own env file
|
|
||||||
# beside the tracked one and re-running ./install.
|
|
||||||
#
|
|
||||||
# This is personal content, so it obeys the same interview decision link-user
|
|
||||||
# does. A machine that never said yes registers nothing.
|
|
||||||
#
|
|
||||||
# Only Claude Code is handled. Codex stores its servers inside config.toml, and
|
|
||||||
# rewriting a section of somebody's live TOML is a worse failure mode than
|
|
||||||
# leaving two lines for them to paste once.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
|
||||||
warn() { echo -e "\033[1;33m[WARN]\033[0m $*" >&2; }
|
|
||||||
|
|
||||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
|
||||||
MCP_DIR="$PANAMA_PATH/user/agents/mcp"
|
|
||||||
SERVERS="$MCP_DIR/servers"
|
|
||||||
ENV_FILE="$MCP_DIR/env"
|
|
||||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
|
|
||||||
DECISION="$STATE_DIR/user-content"
|
|
||||||
|
|
||||||
[[ -r "$SERVERS" ]] || { log "No MCP server list; nothing to register."; exit 0; }
|
|
||||||
|
|
||||||
# The same gate link-user uses, read the same way, so one answer governs all
|
|
||||||
# personal content rather than two stages disagreeing about it.
|
|
||||||
decision="$([[ -r "$DECISION" ]] && cat "$DECISION" || printf 'no')"
|
|
||||||
if [[ "$decision" != "yes" ]]; then
|
|
||||||
log "Personal content is not enabled on this machine; no MCP servers registered."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v claude >/dev/null 2>&1; then
|
|
||||||
log "Claude Code is not installed; nothing to register."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Tokens are optional. A machine without the env file still registers any server
|
|
||||||
# that needs no header, and says which ones it skipped rather than failing.
|
|
||||||
if [[ -r "$ENV_FILE" ]]; then
|
|
||||||
set -a
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. "$ENV_FILE"
|
|
||||||
set +a
|
|
||||||
else
|
|
||||||
warn "No $ENV_FILE; servers needing a token will be skipped."
|
|
||||||
fi
|
|
||||||
|
|
||||||
registered=0
|
|
||||||
skipped=0
|
|
||||||
|
|
||||||
while read -r name transport url token_var _rest; do
|
|
||||||
case "${name:-}" in ''|'#'*) continue ;; esac
|
|
||||||
if [[ -z "${transport:-}" || -z "${url:-}" ]]; then
|
|
||||||
warn "Ignoring malformed row for '$name'."
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
header=()
|
|
||||||
if [[ -n "${token_var:-}" ]]; then
|
|
||||||
token="${!token_var:-}"
|
|
||||||
if [[ -z "$token" ]]; then
|
|
||||||
warn "Skipping $name: $token_var is not set in $ENV_FILE."
|
|
||||||
skipped=$((skipped + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
header=(-H "Authorization: $token")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Re-registering is how this stays idempotent across upgrades, and how a
|
|
||||||
# rotated token reaches the runtime. Removing first avoids the CLI refusing
|
|
||||||
# a name it already knows. Neither call may print the token, so both are
|
|
||||||
# quiet unless they fail.
|
|
||||||
claude mcp remove "$name" -s user >/dev/null 2>&1 || true
|
|
||||||
if claude mcp add --transport "$transport" "$name" "$url" "${header[@]}" \
|
|
||||||
-s user >/dev/null 2>&1; then
|
|
||||||
log "Registered $name."
|
|
||||||
registered=$((registered + 1))
|
|
||||||
else
|
|
||||||
warn "Could not register $name."
|
|
||||||
skipped=$((skipped + 1))
|
|
||||||
fi
|
|
||||||
done <"$SERVERS"
|
|
||||||
|
|
||||||
log "MCP servers: $registered registered, $skipped skipped."
|
|
||||||
log "Claude Code loads them at start, so restart a running session to pick them up."
|
|
||||||
+19
-21
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,24 +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" ]]; then
|
receipt="$built/.panama-source-sha256"
|
||||||
newer_source=''
|
if ! source_digest="$(_vicinae_extension_digest "$extension")"; then
|
||||||
if newer_source="$(find "$extension/src" -type f -newer "$built" -print -quit)" \
|
printf 'Vicinae extension %s inputs could not be verified; skipping\n' \
|
||||||
&& [[ -z "$newer_source" \
|
"$name" >&2
|
||||||
&& ! "$extension/package.json" -nt "$built" \
|
continue
|
||||||
&& ! "$extension/package-lock.json" -nt "$built" ]]; then
|
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
|
||||||
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
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ live-desktop tests/quickshell/dock-position-contract
|
|||||||
# Reads Hyprland option descriptions from the live compositor to verify enum
|
# Reads Hyprland option descriptions from the live compositor to verify enum
|
||||||
# mappings.
|
# mappings.
|
||||||
live-compositor tests/quickshell/enum-hypr-map-contract
|
live-compositor tests/quickshell/enum-hypr-map-contract
|
||||||
hermetic tests/quickshell/exclusion-idiom-contract
|
|
||||||
hermetic tests/quickshell/fingerprint-contract
|
hermetic tests/quickshell/fingerprint-contract
|
||||||
# Reads the host firewall state through the production firewall helper.
|
# Reads the host firewall state through the production firewall helper.
|
||||||
live-host tests/quickshell/firewall-contract
|
live-host tests/quickshell/firewall-contract
|
||||||
@@ -248,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
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# A window that wants the whole output must ask for ExclusionMode.Ignore and
|
|
||||||
# nothing else. Quickshell's exclusiveZone setter forces exclusionMode back to
|
|
||||||
# Normal as a side effect, so a window declaring both
|
|
||||||
#
|
|
||||||
# exclusiveZone: 0
|
|
||||||
# exclusionMode: ExclusionMode.Ignore
|
|
||||||
#
|
|
||||||
# ends up with whichever property the QML engine applied last, and that order
|
|
||||||
# is not ours to control: the 2026-09-14 Qt/Quickshell update flipped it, every
|
|
||||||
# full-screen overlay slid down under the bar, and the screenshot picker's
|
|
||||||
# frozen frame stopped lining up with the screen it was a picture of. This pins
|
|
||||||
# the order-independent idiom: Ignore alone, never paired with exclusiveZone.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
qs="$repo_dir/config/dot/quickshell"
|
|
||||||
|
|
||||||
fail() { printf 'exclusion idiom contract: %s\n' "$1" >&2; exit 1; }
|
|
||||||
[[ -d "$qs/modules" ]] || fail "missing $qs/modules"
|
|
||||||
|
|
||||||
python3 - "$qs" <<'PY'
|
|
||||||
import re, sys, pathlib
|
|
||||||
qs = pathlib.Path(sys.argv[1])
|
|
||||||
problems = []
|
|
||||||
ignore = re.compile(r'\bexclusionMode\s*:\s*ExclusionMode\.Ignore\b')
|
|
||||||
zone = re.compile(r'^\s*(?:WlrLayershell\.)?exclusiveZone\s*:', re.M)
|
|
||||||
checked = 0
|
|
||||||
for path in sorted(qs.rglob('*.qml')):
|
|
||||||
text = path.read_text()
|
|
||||||
if not ignore.search(text):
|
|
||||||
continue
|
|
||||||
checked += 1
|
|
||||||
for m in zone.finditer(text):
|
|
||||||
line = text.count('\n', 0, m.start()) + 1
|
|
||||||
problems.append(f"{path.relative_to(qs)}:{line}: exclusiveZone set in a file that uses "
|
|
||||||
"ExclusionMode.Ignore; drop it, Ignore already means -1")
|
|
||||||
if checked == 0:
|
|
||||||
problems.append("no window uses ExclusionMode.Ignore; the overlays this pins are gone")
|
|
||||||
if problems:
|
|
||||||
print('\n'.join(problems), file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
PY
|
|
||||||
@@ -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"/*)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
|
||||||
@@ -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=()
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ trap 'rm -rf "$work"' EXIT
|
|||||||
|
|
||||||
filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")"
|
filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")"
|
||||||
loop="$(sed -n '/^install_extra_category()/,/^}/p' "$installer")"
|
loop="$(sed -n '/^install_extra_category()/,/^}/p' "$installer")"
|
||||||
# install_extra_category verifies the Flathub remote before installing a
|
|
||||||
# flatpak, and records a soft failure when it cannot. Both live outside the
|
|
||||||
# extracted function and have contracts of their own, so they stand in here as
|
|
||||||
# trusted -- what is under test is which targets reach which installer.
|
|
||||||
deps='ensure_flathub_remote() { :; }
|
|
||||||
softly_failed=()'
|
|
||||||
[[ -n "$filter" && -n "$loop" ]] || {
|
[[ -n "$filter" && -n "$loop" ]] || {
|
||||||
printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2
|
printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -105,7 +99,6 @@ LIST
|
|||||||
source "$catalog"
|
source "$catalog"
|
||||||
eval "$filter"
|
eval "$filter"
|
||||||
eval "$loop"
|
eval "$loop"
|
||||||
eval "$deps"
|
|
||||||
install_extra_category "$fixture"
|
install_extra_category "$fixture"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +132,6 @@ printf 'flatpak:org.example.OnlyFlatpak\n' >"$flatpak_only"
|
|||||||
source "$catalog"
|
source "$catalog"
|
||||||
eval "$filter"
|
eval "$filter"
|
||||||
eval "$loop"
|
eval "$loop"
|
||||||
eval "$deps"
|
|
||||||
install_extra_category "$flatpak_only"
|
install_extra_category "$flatpak_only"
|
||||||
)
|
)
|
||||||
flatpak_only_status=$?
|
flatpak_only_status=$?
|
||||||
@@ -157,7 +149,6 @@ grep -q 'flatpak install -y flathub org.example.OnlyFlatpak' <<<"$(cat "$calls"
|
|||||||
source "$catalog"
|
source "$catalog"
|
||||||
eval "$filter"
|
eval "$filter"
|
||||||
eval "$loop"
|
eval "$loop"
|
||||||
eval "$deps"
|
|
||||||
EXTRAS_DIR="$extras_dir"
|
EXTRAS_DIR="$extras_dir"
|
||||||
for extra in ${PANAMA_EXTRAS:-}; do
|
for extra in ${PANAMA_EXTRAS:-}; do
|
||||||
[[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra"
|
[[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra"
|
||||||
|
|||||||
@@ -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' \
|
||||||
|
|||||||
@@ -123,8 +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 --no-index -q \
|
git -C "$repo_dir" check-ignore -q "$extension/node_modules/" 2>/dev/null \
|
||||||
"$extension/node_modules/package.json" 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
|
||||||
@@ -168,65 +167,130 @@ 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'
|
||||||
|
|
||||||
# Editing an existing source file does not change its parent directory's
|
# Successful builds carry a digest receipt over both manifests and every
|
||||||
# timestamp, so freshness must inspect files rather than the src directory.
|
# source file. Directory mtimes do not change when an existing file is edited,
|
||||||
freshness_root="$fixture_root/freshness"
|
# so each byte class must independently invalidate the build.
|
||||||
mkdir -p "$freshness_root/config/local/share/vicinae/scripts" \
|
digest_root="$fixture_root/digest"
|
||||||
"$freshness_root/config/local/share/vicinae/extensions/panama-search/src" \
|
digest_extension="$digest_root/config/local/share/vicinae/extensions/panama-search"
|
||||||
"$freshness_root/bin"
|
digest_data="$digest_root/vicinae-data"
|
||||||
freshness_extension="$freshness_root/config/local/share/vicinae/extensions/panama-search"
|
mkdir -p "$digest_root/config/local/share/vicinae/scripts" \
|
||||||
cp -- "$manifest" "$freshness_extension/package.json"
|
"$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" \
|
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/package-lock.json" \
|
||||||
"$freshness_extension/package-lock.json"
|
"$digest_extension/package-lock.json"
|
||||||
cp -- "$extension/src/search.tsx" "$freshness_extension/src/search.tsx"
|
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/src/search.tsx" \
|
||||||
cat >"$freshness_root/bin/npm" <<'EOF'
|
"$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
|
#!/usr/bin/env bash
|
||||||
printf '%s\n' "$*" >>"${NPM_LOG:?}"
|
printf '%s\n' "$*" >>"${NPM_LOG:?}"
|
||||||
if [[ "${1:-}" == ci ]]; then
|
case "${1:-}:${2:-}" in
|
||||||
exit 0
|
ci:--silent) exit 0 ;;
|
||||||
fi
|
run:build)
|
||||||
if [[ "${1:-} ${2:-}" == 'run build' ]]; then
|
mkdir -p "$VICINAE_DATA_DIR/extensions/$(basename "$PWD")"
|
||||||
built="${VICINAE_DATA_DIR:?}/extensions/$(basename "$PWD")"
|
printf 'built\n' >"$VICINAE_DATA_DIR/extensions/$(basename "$PWD")/bundle"
|
||||||
mkdir -p "$built"
|
;;
|
||||||
touch "$built"
|
*) exit 64 ;;
|
||||||
exit 0
|
esac
|
||||||
fi
|
|
||||||
exit 64
|
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$freshness_root/bin/npm"
|
cat >"$digest_root/bin/find" <<'EOF'
|
||||||
: >"$freshness_root/npm.log"
|
#!/usr/bin/env bash
|
||||||
run_freshness_stage() {
|
set -uo pipefail
|
||||||
PATH="$freshness_root/bin:$PATH" PANAMA_PATH="$freshness_root" \
|
status=0
|
||||||
VICINAE_DATA_DIR="$freshness_root/vicinae-data" \
|
/usr/bin/find "$@" || status=$?
|
||||||
NPM_LOG="$freshness_root/npm.log" bash "$stage" >/dev/null 2>&1
|
[[ "${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_freshness_stage || note 'the Vicinae freshness fixture did not build initially'
|
|
||||||
initial_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
|
||||||
run_freshness_stage || note 'the unchanged Vicinae freshness fixture failed'
|
|
||||||
unchanged_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
|
||||||
[[ "$unchanged_builds" == "$initial_builds" ]] \
|
|
||||||
|| note 'an unchanged Vicinae extension rebuilt unnecessarily'
|
|
||||||
touch -d '2030-01-01 UTC' "$freshness_extension/src/search.tsx"
|
|
||||||
run_freshness_stage || note 'the source-changed Vicinae freshness fixture failed'
|
|
||||||
source_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
|
||||||
[[ "$source_changed_builds" -eq $(( initial_builds + 1 )) ]] \
|
|
||||||
|| note 'editing an existing Vicinae source file did not trigger a rebuild'
|
|
||||||
|
|
||||||
built_extension="$freshness_root/vicinae-data/extensions/panama-search"
|
run_digest_stage || note 'the Vicinae digest fixture initial build failed'
|
||||||
touch -r "$built_extension" "$freshness_extension/src/search.tsx"
|
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|
||||||
touch -d '2031-01-01 UTC' "$freshness_extension/package.json"
|
|| note 'the Vicinae digest fixture did not perform its initial locked build'
|
||||||
run_freshness_stage || note 'the manifest-changed Vicinae freshness fixture failed'
|
run_digest_stage || note 'the unchanged Vicinae digest fixture failed'
|
||||||
manifest_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
[[ ! -s "$digest_root/npm.log" ]] \
|
||||||
[[ "$manifest_changed_builds" -eq $(( source_changed_builds + 1 )) ]] \
|
|| note 'an unchanged Vicinae extension rebuilt despite its matching receipt'
|
||||||
|| note 'changing a Vicinae package.json did not trigger a rebuild'
|
|
||||||
|
|
||||||
touch -r "$built_extension" "$freshness_extension/package.json" \
|
for digest_input in src/search.tsx package.json package-lock.json tsconfig.json \
|
||||||
"$freshness_extension/src/search.tsx"
|
assets/extension_icon.svg; do
|
||||||
touch -d '2032-01-01 UTC' "$freshness_extension/package-lock.json"
|
printf '\n// digest mutation: %s\n' "$digest_input" >>"$digest_extension/$digest_input"
|
||||||
run_freshness_stage || note 'the lockfile-changed Vicinae freshness fixture failed'
|
run_digest_stage || note "the Vicinae digest fixture failed after changing $digest_input"
|
||||||
lockfile_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|
||||||
[[ "$lockfile_changed_builds" -eq $(( manifest_changed_builds + 1 )) ]] \
|
|| note "changing existing $digest_input bytes did not rebuild the Vicinae extension"
|
||||||
|| note 'changing a Vicinae package-lock.json did not trigger a rebuild'
|
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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
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
@@ -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" \
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ trap 'rm -rf "$tmp"' EXIT
|
|||||||
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
||||||
link-vicinae-scripts setup-server link-server setup-identity
|
link-vicinae-scripts setup-server link-server setup-identity
|
||||||
install-hardware)
|
install-hardware)
|
||||||
PACKAGE_BEHAVIOR_INPUTS=(
|
|
||||||
setup/lib/artifact-provenance
|
|
||||||
setup/lib/chatgpt-package
|
|
||||||
setup/lib/extras-catalog
|
|
||||||
setup/lib/machine-role
|
|
||||||
)
|
|
||||||
|
|
||||||
copy_hash_inputs() {
|
copy_hash_inputs() {
|
||||||
local root="$1" source relative
|
local root="$1" source relative
|
||||||
@@ -56,10 +50,10 @@ copy_hash_inputs() {
|
|||||||
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
|
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
|
||||||
find "$repo_dir/setup/provenance" -type f -print0
|
find "$repo_dir/setup/provenance" -type f -print0
|
||||||
)
|
)
|
||||||
for relative in "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
mkdir -p "$root/setup/lib"
|
||||||
mkdir -p "$(dirname "$root/$relative")"
|
cp -- "$repo_dir/setup/lib/artifact-provenance" \
|
||||||
cp -- "$repo_dir/$relative" "$root/$relative"
|
"$repo_dir/setup/lib/extras-catalog" \
|
||||||
done
|
"$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
|
||||||
@@ -69,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"
|
||||||
@@ -134,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"
|
||||||
@@ -147,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"
|
||||||
@@ -155,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"
|
||||||
}
|
}
|
||||||
@@ -263,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' "${PACKAGE_BEHAVIOR_INPUTS[@]}"; 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=$?
|
||||||
@@ -290,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 "${PACKAGE_BEHAVIOR_INPUTS[@]}"; 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"
|
||||||
@@ -313,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"
|
||||||
@@ -328,26 +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'
|
||||||
|
|
||||||
# Inputs changed while install-packages was running were not the inputs it
|
# The stage may race its own input receipt. A successful stage that changes a
|
||||||
# consumed at the start. Do not stamp the later bytes as successfully applied.
|
# sourced behavior file must not stamp the new digest as though it were the
|
||||||
build_fixture "$tmp/hash-mid-stage-drift"
|
# bytes used to decide this run.
|
||||||
cat >"$tmp/hash-mid-stage-drift/setup/scripts/install-packages" <<'EOF'
|
build_fixture "$tmp/hash-drift"
|
||||||
|
cat >"$tmp/hash-drift/setup/scripts/install-packages" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
if [[ "${1:-}" == --trust-preflight ]]; then
|
if [[ "${1:-}" == --trust-preflight ]]; then
|
||||||
printf 'trust-preflight\n' >>"$PANAMA_RAN"
|
printf 'trust-preflight\n' >>"$PANAMA_RAN"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
printf 'install-packages\n' >>"$PANAMA_RAN"
|
printf 'install-packages\n' >>"$PANAMA_RAN"
|
||||||
printf '\nchanged during package installation\n' >>"$PANAMA_PATH/setup/lib/machine-role"
|
printf '# changed during package stage\n' >>"$PANAMA_PATH/setup/lib/machine-role"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$tmp/hash-mid-stage-drift/setup/scripts/install-packages"
|
chmod +x "$tmp/hash-drift/setup/scripts/install-packages"
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/hash-mid-stage-drift" --upgrade >/dev/null || install_status=$?
|
run_install "$tmp/hash-drift" --upgrade >/dev/null || install_status=$?
|
||||||
[[ "$install_status" -ne 0 ]] \
|
[[ "$install_status" -ne 0 ]] \
|
||||||
|| note 'mid-stage package input drift returned success'
|
|| note 'mid-stage package input drift returned success'
|
||||||
[[ ! -e "$tmp/hash-mid-stage-drift/state/panama/packages-hash" ]] \
|
[[ ! -e "$tmp/hash-drift/state/panama/packages-hash" ]] \
|
||||||
|| note 'mid-stage package input drift stamped bytes the stage did not start with'
|
|| 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
|
||||||
@@ -360,48 +454,30 @@ fi
|
|||||||
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
||||||
|| note 'an ordinary package-stage failure no longer allows later safe stages'
|
|| note 'an ordinary package-stage failure no longer allows later safe stages'
|
||||||
|
|
||||||
# An untrusted Terra root is not an ordinary package failure, and it is not a
|
# An invalid enabled Terra root is not an ordinary package failure. It must
|
||||||
# reason to abandon the machine either. It suppresses the stages that open DNF
|
# stop before the installer's bootstrap DNF and before every stage.
|
||||||
# and the migrations, which are free to run a transaction of their own. Every
|
|
||||||
# stage that only links configuration still runs, and the status stays 78.
|
|
||||||
SAFE_STAGES=(link-dotfiles link-skills link-user link-vicinae-scripts)
|
|
||||||
DNF_SUPPRESSED=(install-packages change-settings install-hardware)
|
|
||||||
|
|
||||||
assert_trust_refusal() {
|
|
||||||
local root="$1" label="$2" suppressed safe
|
|
||||||
for suppressed in "${DNF_SUPPRESSED[@]}"; do
|
|
||||||
grep -qx "$suppressed" "$root/ran" \
|
|
||||||
&& note "$label still ran $suppressed"
|
|
||||||
done
|
|
||||||
grep -q '^migrate ' "$root/ran" \
|
|
||||||
&& note "$label still ran migrations, which may open a DNF transaction"
|
|
||||||
for safe in "${SAFE_STAGES[@]}"; do
|
|
||||||
grep -qx "$safe" "$root/ran" \
|
|
||||||
|| note "$label suppressed $safe, which touches no repository"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
build_fixture "$tmp/terra-preflight-hard" 0 78
|
build_fixture "$tmp/terra-preflight-hard" 0 78
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
|
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
|
||||||
[[ "$install_status" -eq 78 ]] \
|
[[ "$install_status" -eq 78 ]] \
|
||||||
|| note "initial Terra trust failure returned $install_status instead of 78"
|
|| note "initial Terra trust failure returned $install_status instead of 78"
|
||||||
grep -qx 'trust-preflight' "$tmp/terra-preflight-hard/ran" \
|
asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")"
|
||||||
|| note 'initial Terra trust fixture never reached the preflight'
|
[[ "$asserted_preflight" == trust-preflight ]] \
|
||||||
assert_trust_refusal "$tmp/terra-preflight-hard" 'initial Terra trust failure'
|
|| note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}"
|
||||||
|
|
||||||
# The trust verifier is itself mandatory. Losing its executable adapter refuses
|
# The trust verifier is itself mandatory. Losing its executable adapter must
|
||||||
# package work exactly as a failing verdict does, rather than being ignored.
|
# fail closed before interview, bootstrap, or stage work.
|
||||||
build_fixture "$tmp/terra-preflight-missing"
|
build_fixture "$tmp/terra-preflight-missing"
|
||||||
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
|
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
|
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
|
||||||
[[ "$install_status" -eq 78 ]] \
|
[[ "$install_status" -eq 78 ]] \
|
||||||
|| note "missing Terra trust verifier returned $install_status instead of 78"
|
|| note "missing Terra trust verifier returned $install_status instead of 78"
|
||||||
assert_trust_refusal "$tmp/terra-preflight-missing" 'missing Terra trust verifier'
|
[[ ! -s "$tmp/terra-preflight-missing/ran" ]] \
|
||||||
|
|| note 'missing Terra trust verifier allowed later work'
|
||||||
|
|
||||||
# The package stage repeats the preflight to close a configuration-change race.
|
# The package stage repeats the preflight to close a configuration-change race.
|
||||||
# Its hard status suppresses the DNF stages that would have followed it.
|
# Its hard status must also stop link stages and install-hardware immediately.
|
||||||
build_fixture "$tmp/terra-stage-hard" 78 0
|
build_fixture "$tmp/terra-stage-hard" 78 0
|
||||||
install_status=0
|
install_status=0
|
||||||
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
||||||
@@ -409,15 +485,67 @@ run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
|||||||
|| note "stage-time Terra trust failure returned $install_status instead of 78"
|
|| note "stage-time Terra trust failure returned $install_status instead of 78"
|
||||||
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|
||||||
|| note 'stage-time Terra trust fixture never reached install-packages'
|
|| note 'stage-time Terra trust fixture never reached install-packages'
|
||||||
for suppressed in change-settings install-hardware; do
|
for suppressed in link-dotfiles link-skills link-user change-settings install-hardware dnf-transaction; do
|
||||||
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
|
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
|
||||||
&& note "stage-time Terra trust failure still ran $suppressed"
|
&& note "stage-time Terra trust failure still ran $suppressed"
|
||||||
done
|
done
|
||||||
grep -q '^migrate ' "$tmp/terra-stage-hard/ran" \
|
|
||||||
&& note 'stage-time Terra trust failure still ran migrations'
|
# Exercise the complete real package entrypoint at the second boundary. The
|
||||||
for safe in "${SAFE_STAGES[@]}"; do
|
# outer preflight sees no Terra repository; the same DNF adapter exposes an
|
||||||
grep -qx "$safe" "$tmp/terra-stage-hard/ran" \
|
# unsafe enabled Terra identity to the package stage's own preflight. Removing
|
||||||
|| note "stage-time Terra trust failure suppressed $safe"
|
# 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
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -62,40 +62,6 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# The prose bar
|
|
||||||
|
|
||||||
Anything a person other than me will read gets a pattern pass before it ships. MR and PR
|
|
||||||
descriptions, ticket fields, deliverable documents, reports, anything handed to a
|
|
||||||
teammate or a client. Invoke the `unslop` skill and apply its pattern detection.
|
|
||||||
|
|
||||||
- Skip its "Adding soul" section. Opinions and deliberate mess are right for an essay and
|
|
||||||
wrong for a risk table or a status field. These want plain, factual, and specific.
|
|
||||||
- No em dashes, en dashes, semicolons, or arrow glyphs in prose. This is stricter than
|
|
||||||
unslop's own punctuation pattern, and mine wins. Grep the file before calling it done.
|
|
||||||
- Passive voice is the usual offender in technical writing, not puffery. "X was verified"
|
|
||||||
wants to be "I verified X", which is shorter and says who did it.
|
|
||||||
- Short reference cells still count. Three sentences wrapped around a filename is prose.
|
|
||||||
- Never rewrite generated output. If a tool produced a block, an audit verdict or a test
|
|
||||||
report, regenerate it rather than editing its words.
|
|
||||||
- Write it as me, in first person. Never use my name or a stand-in for it. Not "Gib
|
|
||||||
decided", not "the author", not "per the developer". If a sentence needs an actor, it
|
|
||||||
is "I".
|
|
||||||
- Never flag one of my own decisions as a deviation. Don't write that something was out
|
|
||||||
of scope, that an AC or dev review said otherwise, that an earlier story should have
|
|
||||||
caught it, or that a direction was reversed. I write the tickets, so I am allowed to
|
|
||||||
change them, and a dev review is a proposal rather than a contract. Calling it out
|
|
||||||
reads as my own AI tattling on me. It waves a red flag at a nothing burger, costs the
|
|
||||||
PM time investigating a non-issue, and leaves them thinking I did something wrong.
|
|
||||||
State what the change is and why it belongs where it is, then stop.
|
|
||||||
- If a decision feels big enough that you want to narrate it, ask me about it before
|
|
||||||
writing rather than annotating it in the document. Being aligned first is the point,
|
|
||||||
and the writing should read as though we always were.
|
|
||||||
|
|
||||||
Files only I read are exempt: plans, scratch notes, working records. Those can name me
|
|
||||||
and record who decided what, because that history is useful.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Merge Requests
|
# Merge Requests
|
||||||
|
|
||||||
- Make sure titles follow conventions from the repo.
|
- Make sure titles follow conventions from the repo.
|
||||||
@@ -118,8 +84,5 @@ and record who decided what, because that history is useful.
|
|||||||
- Henry reviews all of my code & up until recently, would also write all the dev reviews for all the stories I completed as well. Nowadays, I write them myself & he reviews those too.
|
- Henry reviews all of my code & up until recently, would also write all the dev reviews for all the stories I completed as well. Nowadays, I write them myself & he reviews those too.
|
||||||
- Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime.
|
- Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime.
|
||||||
- Despite the fact that Command Center does not have many users & the impact of it being down for a few minutes is small, Henry still leans on the side of solutions to problems that don't result in prod being down ever, even for just a few minutes during the build process. So our solutions should always keep that in mind. Any code that isn't considering everything & could result in a bug will probably be flagged by him, so its worthwhile to do right the first time!
|
- Despite the fact that Command Center does not have many users & the impact of it being down for a few minutes is small, Henry still leans on the side of solutions to problems that don't result in prod being down ever, even for just a few minutes during the build process. So our solutions should always keep that in mind. Any code that isn't considering everything & could result in a bug will probably be flagged by him, so its worthwhile to do right the first time!
|
||||||
- Josh - Tech Lead for the APSCA project (apsca_next)
|
|
||||||
- On APSCA, Josh is my lead instead of Henry. He reviews my code and writes the dev reviews, spikes, and proposed schemas on the APSCA stories.
|
|
||||||
- His dev reviews are detailed and often include draft code, schema, and state machines. Treat them as the starting point for a story, not as a finished spec.
|
|
||||||
- Hunter Southworth - Engineering Manager / Senior Developer
|
- Hunter Southworth - Engineering Manager / Senior Developer
|
||||||
- Kelson - Owner of Ksense
|
- Kelson - Owner of Ksense
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# MCP servers registered with the local agent runtimes.
|
|
||||||
#
|
|
||||||
# <name> <transport> <url> <token variable>
|
|
||||||
#
|
|
||||||
# The token variable names an entry in user/agents/mcp/env, which is ignored by
|
|
||||||
# git. This file carries no secret, so it is tracked like the rest of user/ and
|
|
||||||
# a new machine gets the same servers by dropping its own env file beside it.
|
|
||||||
#
|
|
||||||
# A row with no token variable registers without an Authorization header.
|
|
||||||
#
|
|
||||||
# Lines beginning with # are comments and blank lines are ignored.
|
|
||||||
|
|
||||||
# The NanoKVM boards. These give an agent full console control of each machine,
|
|
||||||
# including the parts SSH cannot reach: firmware, a locked screen, and the GUI
|
|
||||||
# prompts Xcode and the keychain raise during a signed iOS build.
|
|
||||||
nanokvm-fedora http https://nano.gbrown.org/api/mcp NANOKVM_FEDORA_TOKEN
|
|
||||||
nanokvm-mac http https://macnano.gbrown.org/api/mcp NANOKVM_MAC_TOKEN
|
|
||||||
|
|
||||||
# Bill Tracker's hosted MCP server. No token: it is an OAuth server of its own
|
|
||||||
# and Claude Code signs in through Gib's Auth on first use.
|
|
||||||
bill-tracker http https://mcp.billtracker.gbrown.org/mcp
|
|
||||||
@@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,63 +108,6 @@ rerun it, and verify the device log instead of accepting the fallback transcript
|
|||||||
- Don't check a checklist box (in `mr.md` or anywhere else) unless you actually verified
|
- Don't check a checklist box (in `mr.md` or anywhere else) unless you actually verified
|
||||||
it. Leave it unchecked and say why in the notes rather than guessing.
|
it. Leave it unchecked and say why in the notes rather than guessing.
|
||||||
|
|
||||||
## The prose bar (anything another person reads)
|
|
||||||
|
|
||||||
Everything this skill produces for someone else gets a pattern pass before it ships:
|
|
||||||
`mr.md`'s Summary and Additional Notes, every Jira field including the proof and
|
|
||||||
mitigation cells, and every deliverable document. Call the Skill tool with "unslop" and
|
|
||||||
apply its pattern detection.
|
|
||||||
|
|
||||||
- **Skip unslop's "Adding soul" section.** It asks for opinions, varied rhythm, and some
|
|
||||||
mess. That is right for an essay and wrong for a mitigation cell Henry is auditing.
|
|
||||||
These want plain, factual, and specific.
|
|
||||||
- **Never edit generated output.** The pre-MR handoff pasted into `mr.md` is the audit's
|
|
||||||
own words. If it reads badly, regenerate it, don't rewrite it.
|
|
||||||
- **Proof cells are prose too.** A cell that wraps three sentences around a filename is
|
|
||||||
writing, not a citation, and it picks up passive voice and jargon like any other
|
|
||||||
paragraph. Real examples caught this way: "affordance" for button, "vacuously" for
|
|
||||||
"for the wrong reason", "the test was confirmed able to fail" for "I emptied the
|
|
||||||
legacy render and confirmed the test fails".
|
|
||||||
- **Passive voice is the usual offender here,** not puffery. Technical writing drifts
|
|
||||||
into "X was verified" when "I verified X" is shorter and says who did it.
|
|
||||||
|
|
||||||
### Write as Gabriel, and never flag his own decisions
|
|
||||||
|
|
||||||
Everything that ships under his name is written **as him**, in first person. Never write
|
|
||||||
his name or any stand-in for it. Not "Gib decided", not "the author", not "per the
|
|
||||||
developer". When a sentence needs an actor, it is "I".
|
|
||||||
|
|
||||||
**Never frame one of his decisions as a deviation.** Do not write that something "was not
|
|
||||||
in this story's scope", that an AC or dev review said otherwise, that an earlier story
|
|
||||||
"should have caught it", or that a direction was "reversed". State what the change is and
|
|
||||||
why it belongs where it is, then stop.
|
|
||||||
|
|
||||||
He writes the tickets himself, so he is entitled to change them, and a dev review is a
|
|
||||||
proposal rather than a contract. Calling the change out reads as his own AI tattling on
|
|
||||||
him. It waves a red flag at a nothing burger, costs the PM time investigating a
|
|
||||||
non-issue, and leaves them assuming he did something wrong. The real story is normally
|
|
||||||
mundane and good: the better component already existed and cost almost nothing to use.
|
|
||||||
|
|
||||||
Compare, on KACP-23058:
|
|
||||||
|
|
||||||
> Bad: "This was not in this story's scope. KACP-23059 shipped that page still on the
|
|
||||||
> legacy tables, and Gib reversed the reuse-legacy direction on 2026-09-02."
|
|
||||||
>
|
|
||||||
> Good: "Both detail pages now share one set of grid components instead of two divergent
|
|
||||||
> implementations, so the move ships with the components it shares."
|
|
||||||
|
|
||||||
Same information, no flag attached. **If a decision feels big enough that you want to
|
|
||||||
narrate it, that is the signal to ask him about it before writing, not to annotate it in
|
|
||||||
the document.** Being aligned first is the point, and the writing should read as though
|
|
||||||
you always were.
|
|
||||||
|
|
||||||
This governs `mr.md`, `customfield_10260` and `10261`, every other Jira field, and PM
|
|
||||||
deliverables. It does not govern files only he reads. `plan.md`, `bugs.md`, audits and
|
|
||||||
working notes can name him and record who decided what, because that history is useful.
|
|
||||||
|
|
||||||
The House style punctuation rule above is the stricter one where the two overlap. Keep
|
|
||||||
it. Personal working files (`plan.md`, `ticket.md`) are exempt, nobody else reads them.
|
|
||||||
|
|
||||||
## Verification tools available
|
## Verification tools available
|
||||||
|
|
||||||
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
|
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
|
||||||
@@ -283,19 +226,15 @@ method for producing accurate screenshots, not part of the deliverable's story.
|
|||||||
Process, per deliverable:
|
Process, per deliverable:
|
||||||
|
|
||||||
1. Write `<slug>.md`.
|
1. Write `<slug>.md`.
|
||||||
2. Run the prose bar over `<slug>.md` before building anything from it. A spike
|
2. Write `<slug>.typ` from it, per the styling notes above.
|
||||||
deliverable is the most prose-heavy thing this skill produces and it goes straight to
|
3. Compile: `typst compile <slug>.typ <slug>.pdf`. `typst` should already be on PATH;
|
||||||
Conrad, who is skeptical of AI. Fixing it here means fixing it once, rather than in
|
|
||||||
the markdown and again in the Typst.
|
|
||||||
3. Write `<slug>.typ` from it, per the styling notes above.
|
|
||||||
4. Compile: `typst compile <slug>.typ <slug>.pdf`. `typst` should already be on PATH;
|
|
||||||
if it isn't, tell the user rather than silently skipping the PDF.
|
if it isn't, tell the user rather than silently skipping the PDF.
|
||||||
5. Actually look at the compiled PDF using the Read tool (it reads PDFs directly, page
|
4. Actually look at the compiled PDF using the Read tool (it reads PDFs directly, page
|
||||||
by page for longer documents). Check every page for real layout problems: text or
|
by page for longer documents). Check every page for real layout problems: text or
|
||||||
a table overflowing a page, an awkward page break splitting a table or diagram,
|
a table overflowing a page, an awkward page break splitting a table or diagram,
|
||||||
cramped or excessive spacing, a diagram that rendered wrong. A successful compile
|
cramped or excessive spacing, a diagram that rendered wrong. A successful compile
|
||||||
only means valid Typst, not that it looks right — actually look.
|
only means valid Typst, not that it looks right — actually look.
|
||||||
6. If anything looks wrong, fix `<slug>.typ` and go back to step 4. Repeat until the
|
5. If anything looks wrong, fix `<slug>.typ` and go back to step 3. Repeat until the
|
||||||
PDF genuinely looks right, not just until it compiles without erroring.
|
PDF genuinely looks right, not just until it compiles without erroring.
|
||||||
|
|
||||||
If `.claude/docs/` is tracked by git in this repo (check with
|
If `.claude/docs/` is tracked by git in this repo (check with
|
||||||
@@ -433,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
|
||||||
@@ -681,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
|
||||||
@@ -732,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`
|
||||||
@@ -782,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.
|
||||||
@@ -817,10 +742,13 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
content. A reviewer should be able to read the whole file in about a minute; if it
|
content. A reviewer should be able to read the whole file in about a minute; if it
|
||||||
has grown past roughly 120 lines including the handoff, it has drifted.
|
has grown past roughly 120 lines including the handoff, it has drifted.
|
||||||
|
|
||||||
Before saving, run the prose bar (see **The prose bar** above) over the Summary and
|
Before saving, call the Skill tool with "unslop" and apply its pattern detection to
|
||||||
Additional Notes ONLY. The pasted handoff is generated output and is never edited.
|
the Summary and Additional Notes ONLY: puffery, superficial -ing phrases, "not just
|
||||||
Henry reads every one of these, and prose that reads as machine-generated costs the
|
X but Y", vague attributions, rule-of-three padding. The pasted handoff is generated
|
||||||
MR its credibility before anyone looks at the diff.
|
output and is never edited, per the rule above. Skip unslop's "Adding soul" section
|
||||||
|
too — an MR description wants plain and factual, not voice. Henry reads every one of
|
||||||
|
these, and prose that reads as machine-generated costs the MR its credibility before
|
||||||
|
anyone looks at the diff.
|
||||||
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
|
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
|
||||||
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
||||||
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
||||||
@@ -829,9 +757,11 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
field-to-type map below is verified against the project's edit screens, don't PUT
|
field-to-type map below is verified against the project's edit screens, don't PUT
|
||||||
a field to a type that doesn't carry it.
|
a field to a type that doesn't carry it.
|
||||||
|
|
||||||
**Run the prose bar** (see **The prose bar** above) over every field before rendering
|
**Same prose bar as `mr.md`.** Every field authored here is read by the Lead and the
|
||||||
it to ADF, proof and mitigation cells included. Everything here is read by the Lead
|
PM. Run the `unslop` skill's pattern detection over the markdown before rendering it
|
||||||
and the PM.
|
to ADF, skipping its "Adding soul" section — Jira fields want plain, factual, and
|
||||||
|
specific. This does not apply to proof cells, which are references to artifacts and
|
||||||
|
test names rather than prose.
|
||||||
|
|
||||||
**Proof first.** Before filling any proof column, capture working feature proof
|
**Proof first.** Before filling any proof column, capture working feature proof
|
||||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
||||||
@@ -891,12 +821,13 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
handled and what covers it. Deliver the number asked for, not more, and pick the
|
handled and what covers it. Deliver the number asked for, not more, and pick the
|
||||||
ones a reviewer most benefits from. This field, not `mr.md`.
|
ones a reviewer most benefits from. This field, not `mr.md`.
|
||||||
- `customfield_10261` User Story Patch Notes: the same change in user-facing words.
|
- `customfield_10261` User Story Patch Notes: the same change in user-facing words.
|
||||||
- `customfield_10142` Working Feature Proof: leave it alone. Write nothing here.
|
- `customfield_10142` Working Feature Proof: ALWAYS fill this on a Story, never
|
||||||
This field is where Gib drops the proof attachments, and dragging files into it
|
leave it empty. It renders as its own panel in the ticket's testing section, so
|
||||||
replaces whatever text is sitting there, so a written list is destroyed by the
|
an empty field reads as unfilled testing even when every test-table cell is
|
||||||
upload it was describing. Naming each artifact is already the job of the Test
|
complete (this happened on KACP-23143). At minimum it lists the files staged in
|
||||||
Cases proof cells, which reference it by filename. Step 10 tells Gib which files
|
`proof/` for Gib to attach, one line each saying what the artifact shows, plus a
|
||||||
to upload. Do not PUT this field on a Story.
|
pointer that per-row proof lives in the Test Cases table. A headline artifact (a
|
||||||
|
demo capture, a before/after pair) leads the list when one exists.
|
||||||
|
|
||||||
**Spike** (deliverables, not code): no test or proof fields to fill. The output is
|
**Spike** (deliverables, not code): no test or proof fields to fill. The output is
|
||||||
the deliverables directory, and step 10 tells Gib which files to upload where (the
|
the deliverables directory, and step 10 tells Gib which files to upload where (the
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user