Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85160ffef2 | ||
|
|
59192143c5 | ||
|
|
2f2e3b88e8 | ||
|
|
9156a7e05c | ||
|
|
39a2e8e9ca | ||
|
|
6206565d95 | ||
|
|
cceb7a707b | ||
|
|
c1bbc69c8a | ||
|
|
fa8b14e05e | ||
|
|
b5832fc94a | ||
|
|
99156442b5 | ||
|
|
4c27203214 | ||
|
|
c71d6c8799 | ||
|
|
cb305f6662 | ||
|
|
1ee42f2cb6 | ||
|
|
1ae3825fda |
@@ -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
|
||||
drives every rule below.
|
||||
|
||||
Read before large work, in this order:
|
||||
Before large work, read these in order:
|
||||
|
||||
- `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
|
||||
@@ -62,7 +62,8 @@ 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.
|
||||
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||
- Contracts run directly too: `tests/setup/interview-contract`.
|
||||
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
|
||||
- After changing `PreferenceSchema.qml` or
|
||||
`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-commands` (writes the launcher deep links).
|
||||
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/panama
|
||||
@@ -12,6 +12,10 @@
|
||||
/config/dot/espanso/match/identity.yml
|
||||
# Ignore backups of old config files
|
||||
/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!
|
||||
/config/wg/**
|
||||
# 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 |
|
||||
| `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-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-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-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 |
|
||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||
@@ -235,10 +235,13 @@ server/ The server role: compose services (one directory per
|
||||
service), the nightly image updater, and its units. See
|
||||
server/README.md
|
||||
skills/ Agent skills for operating this desktop, linked into
|
||||
~/.claude/skills
|
||||
~/.agents/skills and ~/.claude/skills
|
||||
setup/
|
||||
apps/ Applications built from source, one file each
|
||||
lib/ Shared by more than one stage; the extras catalog reader
|
||||
keys/ Pinned signing keys, for publishers that ship no fetchable
|
||||
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
|
||||
scripts/ Run in order by ./install
|
||||
tests/ Contracts. See below
|
||||
@@ -247,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
|
||||
|
||||
## Tests
|
||||
|
||||
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||
190 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||
contract by the capabilities it needs. Run the hermetic set, or grant a
|
||||
specific external capability when automation needs it:
|
||||
|
||||
|
||||
+8
-2
@@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET}
|
||||
$PROGRAM upgrade
|
||||
$PROGRAM apps
|
||||
$PROGRAM app
|
||||
$PROGRAM app chatgpt-desktop
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -451,11 +450,16 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
|
||||
|
||||
contract_paths() {
|
||||
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
|
||||
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||||
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
|
||||
done < <(find "$PANAMA_DIR/tests" -type f \
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||
}
|
||||
|
||||
contract_manifest_entries() {
|
||||
@@ -478,6 +482,8 @@ validate_contract_manifest() {
|
||||
require_contract_manifest || return 1
|
||||
|
||||
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 previous_path="" capability discovered
|
||||
local -a capability_list=() findings=()
|
||||
|
||||
+3
-2
@@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
|
||||
alias nlc='nvim leetcode.nvim'
|
||||
alias clauded='claude --allow-dangerously-skip-permissions'
|
||||
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'
|
||||
|
||||
# Docker Shortcuts
|
||||
@@ -36,9 +37,9 @@ alias fprint-on='sudo authselect enable-feature with-fingerprint'
|
||||
alias fprint-off='sudo authselect disable-feature with-fingerprint'
|
||||
|
||||
# File system
|
||||
alias ls='eza -lh --group-directories-first --icons'
|
||||
alias ls='eza -lh --group-directories-first --icons=auto'
|
||||
alias lsa='ls -a'
|
||||
alias lt='eza --tree --level=2 --long --icons --git'
|
||||
alias lt='eza --tree --level=2 --long --icons=auto --git'
|
||||
alias lta='lt -a'
|
||||
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
|
||||
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
|
||||
|
||||
@@ -244,10 +244,20 @@ local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
|
||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
||||
-- HBR3, so this relies on DSC. If the display fails to light up or falls back to
|
||||
-- a lower mode, drop this to 8 first.
|
||||
local shipped_bitdepth = 10
|
||||
-- 8-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
|
||||
-- retrains it and blanks the screen. 8bpc keeps headroom on the link.
|
||||
--
|
||||
-- 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.
|
||||
local shipped_cm = "auto"
|
||||
|
||||
@@ -125,6 +125,16 @@ hl.window_rule({
|
||||
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.
|
||||
hl.window_rule({
|
||||
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
|
||||
@@ -269,16 +279,28 @@ hl.layer_rule({
|
||||
ignore_alpha = 0.2,
|
||||
})
|
||||
|
||||
-- Overview, capture and local screen-reading UI dim the desktop behind them.
|
||||
-- Overview and local screen-reading UI dim the desktop behind them.
|
||||
hl.layer_rule({
|
||||
name = "qs-overlay",
|
||||
match = { namespace = "^qs-(overview|capture|screen-intelligence)$" },
|
||||
match = { namespace = "^qs-(overview|screen-intelligence)$" },
|
||||
blur = true,
|
||||
ignore_alpha = 0.4,
|
||||
dim_around = 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
|
||||
-- the cards are a near-transparent fill sitting directly on the wallpaper and
|
||||
-- read as washed out rather than as glass.
|
||||
|
||||
@@ -1805,7 +1805,7 @@ Singleton {
|
||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||
"codex-desktop", "md.obsidian.Obsidian",
|
||||
"chatgpt", "md.obsidian.Obsidian",
|
||||
"com.obsproject.Studio", "steam"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -27,9 +27,13 @@ PanelWindow {
|
||||
right: true
|
||||
}
|
||||
|
||||
// Fullscreen overlays must not reserve space, or every window on the
|
||||
// workspace gets resized as the picker opens and closes.
|
||||
exclusiveZone: 0
|
||||
// Ignore, and nothing else: reserve no space (or every window on the
|
||||
// workspace resizes as the picker opens and closes) and respect nobody's
|
||||
// (or the window starts under the bar, the full-output freeze frame is
|
||||
// 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.layer: WlrLayer.Overlay
|
||||
|
||||
@@ -57,7 +57,6 @@ PanelWindow {
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
|
||||
|
||||
@@ -55,7 +55,6 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
|
||||
// A dock that reserved space would not be intellihiding.
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
|
||||
|
||||
@@ -40,7 +40,6 @@ PanelWindow {
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
||||
|
||||
@@ -44,7 +44,6 @@ PanelWindow {
|
||||
// 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
|
||||
// visible from wherever the eyes happen to be.
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ PanelWindow {
|
||||
screen: root.modelData
|
||||
anchors.bottom: true
|
||||
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
implicitWidth: root.desiredWidth
|
||||
implicitHeight: 64
|
||||
|
||||
@@ -22,7 +22,6 @@ PanelWindow {
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
||||
|
||||
@@ -34,11 +34,23 @@ Item {
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
|
||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||
// is actually on screen rather than running at shell startup. Monitors do
|
||||
// not come and go, so once is enough.
|
||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||
// Probing I2C takes on the order of a second, so it waits until the quick
|
||||
// settings panel is actually on screen rather than running at shell
|
||||
// startup. Monitors do not come and go, so once is enough.
|
||||
//
|
||||
// The trigger is the panel opening, not this item becoming visible: on a
|
||||
// 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
|
||||
Process {
|
||||
|
||||
@@ -22,7 +22,6 @@ Variants {
|
||||
implicitWidth: 260
|
||||
implicitHeight: 172
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
mask: Region {}
|
||||
|
||||
|
||||
@@ -383,6 +383,13 @@ 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
|
||||
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,
|
||||
and it ranges from a half-screen split to the full display. `SliderRow` stacks
|
||||
its control under the label below 520px. Test narrow.
|
||||
|
||||
@@ -78,7 +78,6 @@ PanelWindow {
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-welcome"
|
||||
|
||||
@@ -341,7 +341,10 @@ esac'
|
||||
property bool record: false
|
||||
property bool intelligence: false
|
||||
// A handful of frames at 60Hz, enough for the compositor to recomposite
|
||||
// the output without the overlay on it.
|
||||
// the output without the overlay on it. Only enough because the
|
||||
// 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
|
||||
onTriggered: {
|
||||
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
|
||||
# reviewed installer trust inputs changed, so this hashes them and remembers
|
||||
# the result. The framed, sorted stream includes top-level package files, the
|
||||
# package-stage adapter, the provenance helper, and regular provenance files;
|
||||
# package-stage adapter, every helper it sources, and regular provenance files;
|
||||
# both relative paths and bytes are part of the state.
|
||||
#
|
||||
# A content hash rather than a git range, because Panama is developed in place:
|
||||
@@ -93,7 +93,10 @@ hash_packages() {
|
||||
|
||||
for fixed_input in \
|
||||
"$PANAMA_PATH/setup/scripts/install-packages" \
|
||||
"$PANAMA_PATH/setup/lib/artifact-provenance"; do
|
||||
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||
"$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
|
||||
|
||||
@@ -102,7 +105,10 @@ hash_packages() {
|
||||
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1
|
||||
printf '%s\0' \
|
||||
"$PANAMA_PATH/setup/scripts/install-packages" \
|
||||
"$PANAMA_PATH/setup/lib/artifact-provenance" || exit 1
|
||||
"$PANAMA_PATH/setup/lib/artifact-provenance" \
|
||||
"$PANAMA_PATH/setup/lib/chatgpt-package" \
|
||||
"$PANAMA_PATH/setup/lib/extras-catalog" \
|
||||
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
|
||||
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
|
||||
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
|
||||
relative="${file#"$PANAMA_PATH"/}"
|
||||
@@ -116,12 +122,11 @@ hash_packages() {
|
||||
}
|
||||
|
||||
packages_needed() {
|
||||
local current_hash recorded_hash
|
||||
local current_hash="$1" recorded_hash
|
||||
|
||||
(( FORCE_PACKAGES )) && return 0
|
||||
(( UPGRADE )) || return 0
|
||||
[[ -r "$PACKAGES_HASH" ]] || return 0
|
||||
current_hash="$(hash_packages)" || return 2
|
||||
recorded_hash="$(cat "$PACKAGES_HASH")" || return 2
|
||||
[[ "$current_hash" != "$recorded_hash" ]]
|
||||
}
|
||||
@@ -130,10 +135,12 @@ packages_needed() {
|
||||
# documents for its markers: a step that did not complete has not happened, and
|
||||
# recording it as done hides it forever.
|
||||
record_packages_hash() {
|
||||
local temporary_hash
|
||||
local starting_hash="$1" current_hash temporary_hash
|
||||
current_hash="$(hash_packages)" || return 1
|
||||
[[ "$current_hash" == "$starting_hash" ]] || return 1
|
||||
mkdir -p "$STATE_DIR"
|
||||
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
|
||||
if hash_packages >"$temporary_hash"; then
|
||||
if printf '%s\n' "$starting_hash" >"$temporary_hash"; then
|
||||
mv -f -- "$temporary_hash" "$PACKAGES_HASH"
|
||||
else
|
||||
rm -f -- "$temporary_hash"
|
||||
@@ -142,20 +149,37 @@ record_packages_hash() {
|
||||
}
|
||||
|
||||
# Repository trust is checked before the installer can reach its bootstrap DNF.
|
||||
# Status 78 is reserved for a trust-root failure and is propagated unchanged so
|
||||
# no later stage, especially install-hardware, can invoke DNF with that repo.
|
||||
# Status 78 is reserved for a trust-root failure. It suppresses every stage that
|
||||
# opens DNF -- install-hardware included, which would otherwise pull drivers
|
||||
# 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
|
||||
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"
|
||||
if [[ ! -x "$trust_preflight" ]]; then
|
||||
printf 'install: package repository trust preflight is unavailable\n' >&2
|
||||
exit "$TERRA_TRUST_FAILURE_STATUS"
|
||||
fi
|
||||
if "$trust_preflight" --trust-preflight; then
|
||||
:
|
||||
else
|
||||
trust_status=$?
|
||||
package_trust_refused=1
|
||||
elif ! "$trust_preflight" --trust-preflight; then
|
||||
printf 'install: package repository trust preflight failed\n' >&2
|
||||
exit "$trust_status"
|
||||
package_trust_refused=1
|
||||
fi
|
||||
|
||||
# ── The interview ────────────────────────────────────────────────────────────
|
||||
@@ -175,7 +199,7 @@ fi
|
||||
# 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
|
||||
# not have that stop an upgrade that never needed it.
|
||||
if (( ! UPGRADE )); then
|
||||
if (( ! UPGRADE && ! package_trust_refused )); then
|
||||
bootstrap=()
|
||||
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
|
||||
# The probe tools serve only the hardware questions, which a server is never
|
||||
@@ -319,7 +343,7 @@ fi
|
||||
if [[ "$PANAMA_ROLE" == server ]]; then
|
||||
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
|
||||
else
|
||||
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
|
||||
STAGES=(install-packages link-dotfiles link-skills link-user link-mcp change-settings link-vicinae-scripts setup-identity install-hardware)
|
||||
fi
|
||||
|
||||
# The two an upgrade drops. Both exist only to act on interview answers, and
|
||||
@@ -342,9 +366,16 @@ for stage in "${STAGES[@]}"; do
|
||||
script="$PANAMA_PATH/setup/scripts/$stage"
|
||||
[[ -x "$script" ]] || continue
|
||||
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
|
||||
package_state_status=0
|
||||
packages_needed || package_state_status=$?
|
||||
package_start_hash="$(hash_packages)" || package_state_status=2
|
||||
if (( package_state_status == 0 )); then
|
||||
packages_needed "$package_start_hash" || package_state_status=$?
|
||||
fi
|
||||
if (( package_state_status == 1 )); then
|
||||
echo "The package lists have not changed since the last run; skipping."
|
||||
echo "Run with --packages to install them anyway."
|
||||
@@ -357,16 +388,19 @@ for stage in "${STAGES[@]}"; do
|
||||
fi
|
||||
if "$script"; then
|
||||
if [[ "$stage" == install-packages ]]; then
|
||||
if ! record_packages_hash; then
|
||||
if ! record_packages_hash "$package_start_hash"; then
|
||||
failed+=("$stage")
|
||||
printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2
|
||||
fi
|
||||
fi
|
||||
else
|
||||
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
|
||||
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
|
||||
exit "$stage_status"
|
||||
package_trust_refused=1
|
||||
continue
|
||||
fi
|
||||
failed+=("$stage")
|
||||
printf '!!! %s failed\n' "$stage" >&2
|
||||
@@ -392,8 +426,16 @@ done
|
||||
# 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
|
||||
# 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"
|
||||
if [[ -x "$migrate" ]]; then
|
||||
if (( package_trust_refused )) && [[ -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'
|
||||
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
|
||||
"$migrate" run || failed+=(migrations)
|
||||
@@ -441,6 +483,17 @@ else
|
||||
retry='./install'
|
||||
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 (( UPGRADE )); then
|
||||
echo "Panama is up to date."
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/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."
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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 }'
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8
|
||||
3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd
|
||||
GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe
|
||||
hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw
|
||||
lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J
|
||||
vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k
|
||||
4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/
|
||||
7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9
|
||||
og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9
|
||||
WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W
|
||||
jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB
|
||||
tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo
|
||||
SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ
|
||||
SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev
|
||||
SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe
|
||||
IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF
|
||||
VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo
|
||||
pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89
|
||||
6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu
|
||||
i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS
|
||||
uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E
|
||||
I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV
|
||||
8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712
|
||||
Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno=
|
||||
=Y+3h
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -8,6 +8,7 @@ declare -gA INSTALLER_PROVENANCE=()
|
||||
|
||||
_primary_key_fingerprints() (
|
||||
local home
|
||||
set -o pipefail
|
||||
home="$(mktemp -d)" || exit 1
|
||||
chmod 700 "$home"
|
||||
trap 'rm -rf -- "$home"' EXIT
|
||||
@@ -19,14 +20,20 @@ _primary_key_fingerprints() (
|
||||
|
||||
key_fingerprint_matches() {
|
||||
local file="$1" expected="$2"
|
||||
local output
|
||||
local -a primary_fingerprints=()
|
||||
mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$file")
|
||||
output="$(_primary_key_fingerprints "$file")" || return 1
|
||||
[[ -n "$output" ]] || return 1
|
||||
mapfile -t primary_fingerprints <<<"$output"
|
||||
[[ ${#primary_fingerprints[@]} -eq 1 && "${primary_fingerprints[0]}" == "$expected" ]]
|
||||
}
|
||||
|
||||
_key_has_one_primary() {
|
||||
local output
|
||||
local -a primary_fingerprints=()
|
||||
mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$1")
|
||||
output="$(_primary_key_fingerprints "$1")" || return 1
|
||||
[[ -n "$output" ]] || return 1
|
||||
mapfile -t primary_fingerprints <<<"$output"
|
||||
[[ ${#primary_fingerprints[@]} -eq 1 ]]
|
||||
}
|
||||
|
||||
@@ -105,6 +112,7 @@ load_installer_provenance() {
|
||||
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 \
|
||||
RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \
|
||||
TERRA_METALINK_BASEURL \
|
||||
HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \
|
||||
CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \
|
||||
BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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
|
||||
}
|
||||
@@ -35,6 +35,7 @@ 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_MAX_BYTES=4194304
|
||||
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/
|
||||
FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo
|
||||
FLATHUB_DESCRIPTOR_MAX_BYTES=1048576
|
||||
|
||||
+189
-23
@@ -89,6 +89,11 @@ source "$PANAMA_PATH/setup/lib/extras-catalog"
|
||||
source "$PANAMA_PATH/setup/lib/machine-role"
|
||||
ROLE="$(panama_role)"
|
||||
|
||||
# Establishing the verified ChatGPT repository, shared with the migration that
|
||||
# replaces the community build, so neither can install it a less careful way.
|
||||
# shellcheck source=../lib/chatgpt-package
|
||||
source "$PANAMA_PATH/setup/lib/chatgpt-package"
|
||||
|
||||
# One list, installed the way every list is installed: --skip-unavailable so a
|
||||
# single rotted name cannot cost the transaction, then report_missing so a
|
||||
# skipped name is a warning somebody reads.
|
||||
@@ -720,8 +725,17 @@ _publish_repository_pair() {
|
||||
return "$status"
|
||||
}
|
||||
|
||||
# Reads the gpgkey of the single enabled Terra identity out of dnf's effective
|
||||
# configuration, applying the structural safety rules either way: one enabled
|
||||
# identity, named terra, with every signature check turned on.
|
||||
#
|
||||
# With require_pinned set it additionally demands Panama's own reviewed baseurl
|
||||
# and key path. Without it, the answer is just "what trust root is this machine
|
||||
# actually verifying against?" -- the question adoption turns on.
|
||||
_effective_terra_key() {
|
||||
awk -v reviewed_baseurl="${INSTALLER_PROVENANCE[TERRA_BASEURL]}" '
|
||||
awk -v reviewed_baseurl="${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
|
||||
-v reviewed_metalink="${INSTALLER_PROVENANCE[TERRA_METALINK_BASEURL]}" \
|
||||
-v require_pinned="${1:-}" '
|
||||
function reset_block() {
|
||||
delete values
|
||||
delete seen
|
||||
@@ -741,10 +755,22 @@ _effective_terra_key() {
|
||||
for (key in required) {
|
||||
if (seen[key] != 1) bad = 1
|
||||
}
|
||||
if (values["baseurl"] != reviewed_baseurl || values["metalink"] != "" \
|
||||
|| values["mirrorlist"] != "" || values["gpgcheck"] != "1" \
|
||||
|| values["pkg_gpgcheck"] != "1" || values["repo_gpgcheck"] != "1" \
|
||||
|| values["gpgkey"] != "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama") bad = 1
|
||||
if (require_pinned != "") {
|
||||
if (values["baseurl"] != reviewed_baseurl || values["metalink"] != "" \
|
||||
|| values["mirrorlist"] != "" \
|
||||
|| values["gpgkey"] != "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama") bad = 1
|
||||
} else if (values["mirrorlist"] != "") {
|
||||
bad = 1
|
||||
} else if (values["baseurl"] == reviewed_baseurl && values["metalink"] == "") {
|
||||
# Already on the reviewed baseurl, just not via the Panama key path.
|
||||
} else if (values["baseurl"] == "" \
|
||||
&& index(values["metalink"], reviewed_metalink "?") == 1) {
|
||||
# The stock terra-release metalink, on the reviewed host.
|
||||
} else {
|
||||
bad = 1
|
||||
}
|
||||
if (values["gpgcheck"] != "1" || values["pkg_gpgcheck"] != "1" \
|
||||
|| values["repo_gpgcheck"] != "1" || values["gpgkey"] == "") bad = 1
|
||||
trusted_key = values["gpgkey"]
|
||||
}
|
||||
BEGIN {
|
||||
@@ -802,12 +828,24 @@ _effective_terra_key() {
|
||||
'
|
||||
}
|
||||
|
||||
_terra_repo_config_dump() {
|
||||
LC_ALL=C dnf --quiet --no-plugins --dump-repo-config='*'
|
||||
}
|
||||
|
||||
# Status 0 is one trusted effective Terra identity, 1 is no enabled Terra
|
||||
# identity, and 2 is an unsafe, duplicated, or unreadable effective state.
|
||||
#
|
||||
# Both this and _terra_adoptable_status take an already-read dump when the
|
||||
# caller needs both verdicts, so the two cannot disagree about a configuration
|
||||
# that changed between them.
|
||||
_terra_effective_status() {
|
||||
local dump gpgkey parse_status=0 local_key
|
||||
dump="$(LC_ALL=C dnf --quiet --no-plugins --dump-repo-config='*')" || return 2
|
||||
gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key)" || parse_status=$?
|
||||
if (( $# > 0 )); then
|
||||
dump="$1"
|
||||
else
|
||||
dump="$(_terra_repo_config_dump)" || return 2
|
||||
fi
|
||||
gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key pinned)" || parse_status=$?
|
||||
(( parse_status == 0 )) || return "$parse_status"
|
||||
[[ "$gpgkey" == 'file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' ]] || return 2
|
||||
local_key="$PANAMA_SYSTEM_ETC/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama"
|
||||
@@ -819,18 +857,71 @@ _terra_effective_status() {
|
||||
|| return 2
|
||||
}
|
||||
|
||||
# Whether an effective Terra that is not in Panama's pinned form can be safely
|
||||
# converted into it rather than refused.
|
||||
#
|
||||
# The trust root is the signing key, not the URL it is served from. A machine
|
||||
# that installed Terra the way Terra documents has terra-release's own repo
|
||||
# file: a metalink instead of the reviewed baseurl, and the key at
|
||||
# RPM-GPG-KEY-terra44 rather than Panama's renamed copy. Every signature check
|
||||
# is already on, and that key is the same fingerprint this repository reviewed
|
||||
# and pinned. Cosmetics, in other words -- not a compromised trust root.
|
||||
#
|
||||
# Refusing it outright built a gate with no door. install_terra_repository
|
||||
# declined to touch a machine terra-release had already reached, so an ordinary
|
||||
# Fedora desktop could never reach the pinned state, and a routine `panama
|
||||
# update` died before it ran a single stage. Adoption is the door.
|
||||
#
|
||||
# It is deliberately narrow: the pinned fingerprint must match on both the
|
||||
# reviewed key and the key the machine actually verifies against, and the
|
||||
# gpgkey must be a local file under the system trust directory. An unknown key,
|
||||
# a remote gpgkey, a second enabled Terra, or a disabled signature check is
|
||||
# still a hard refusal.
|
||||
_terra_adoptable_status() {
|
||||
local dump gpgkey parse_status=0 key_file key_path
|
||||
_require_policy_value TERRA_METALINK_BASEURL 'https://tetsudou.fyralabs.com/metalink' || return 2
|
||||
if (( $# > 0 )); then
|
||||
dump="$1"
|
||||
else
|
||||
dump="$(_terra_repo_config_dump)" || return 2
|
||||
fi
|
||||
gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key)" || parse_status=$?
|
||||
(( parse_status == 0 )) || return "$parse_status"
|
||||
key_file="${gpgkey#file://}"
|
||||
[[ "$gpgkey" == "file://$key_file" && "$key_file" == /etc/pki/rpm-gpg/* ]] || return 2
|
||||
[[ "$key_file" != *..* ]] || return 2
|
||||
key_path="$PANAMA_SYSTEM_ETC${key_file#/etc}"
|
||||
[[ -f "$key_path" && ! -L "$key_path" ]] || return 2
|
||||
key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \
|
||||
"${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \
|
||||
&& key_fingerprint_matches "$key_path" \
|
||||
"${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \
|
||||
|| return 2
|
||||
}
|
||||
|
||||
TERRA_TRUST_FAILURE_STATUS=78
|
||||
|
||||
preflight_terra_trust() {
|
||||
local status=0
|
||||
local status=0 adoptable_status=0 dump
|
||||
_require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' \
|
||||
|| return "$TERRA_TRUST_FAILURE_STATUS"
|
||||
_require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \
|
||||
|| return "$TERRA_TRUST_FAILURE_STATUS"
|
||||
_terra_effective_status || status=$?
|
||||
dump="$(_terra_repo_config_dump)" || {
|
||||
log "Effective Terra repository configuration is not trusted; refusing all package work"
|
||||
return "$TERRA_TRUST_FAILURE_STATUS"
|
||||
}
|
||||
_terra_effective_status "$dump" || status=$?
|
||||
if (( status == 0 || status == 1 )); then
|
||||
return 0
|
||||
fi
|
||||
# Terra signed by the pinned key, on a reviewed endpoint, passes here because
|
||||
# install-packages adopts it into the pinned form before it opens any other
|
||||
# DNF transaction.
|
||||
_terra_adoptable_status "$dump" || adoptable_status=$?
|
||||
if (( adoptable_status == 0 )); then
|
||||
return 0
|
||||
fi
|
||||
log "Effective Terra repository configuration is not trusted; refusing all package work"
|
||||
return "$TERRA_TRUST_FAILURE_STATUS"
|
||||
}
|
||||
@@ -902,18 +993,67 @@ install_rpmfusion_repositories() {
|
||||
return "$status"
|
||||
}
|
||||
|
||||
# Rewrites an adoptable Terra into Panama's pinned form.
|
||||
#
|
||||
# No network and no DNF: terra-release is already installed, so this is only the
|
||||
# key copy and the repository file, published as one pair so a half-written
|
||||
# trust root rolls back. The effective state is re-read afterwards, because the
|
||||
# only acceptable proof that adoption worked is the check that judged it.
|
||||
#
|
||||
# terra-release owns /etc/yum.repos.d/terra.repo, so a later update to that
|
||||
# package restores the stock file. That is fine and deliberate: the next run
|
||||
# adopts it again, which is why adoption has to be repeatable rather than a
|
||||
# one-time migration.
|
||||
adopt_terra_repository() {
|
||||
local work staged_key staged_repo status=0 effective_status=0
|
||||
work="$(mktemp -d)" || return 1
|
||||
chmod 0700 "$work"
|
||||
staged_key="$work/terra44.asc"
|
||||
staged_repo="$work/terra.repo"
|
||||
if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \
|
||||
TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then
|
||||
rm -rf -- "$work"
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' \
|
||||
'[terra]' \
|
||||
'name=Panama reviewed Terra 44' \
|
||||
"baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \
|
||||
'enabled=1' \
|
||||
'gpgcheck=1' \
|
||||
'repo_gpgcheck=1' \
|
||||
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$staged_repo"
|
||||
chmod 0600 "$staged_repo"
|
||||
_publish_repository_pair \
|
||||
"$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \
|
||||
"$staged_repo" /etc/yum.repos.d/terra.repo || status=$?
|
||||
if (( status == 0 )); then
|
||||
_terra_effective_status || effective_status=$?
|
||||
(( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS"
|
||||
fi
|
||||
rm -rf -- "$work"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
install_terra_repository() {
|
||||
local work staged_key staged_repo status effective_status=0
|
||||
local work staged_key staged_repo status effective_status=0 adoptable_status=0 dump
|
||||
require_reviewed_fedora_release || return 1
|
||||
_require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' || return 1
|
||||
_require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F || return 1
|
||||
_terra_effective_status || effective_status=$?
|
||||
dump="$(_terra_repo_config_dump)" || return 1
|
||||
_terra_effective_status "$dump" || effective_status=$?
|
||||
if (( effective_status == 0 )); then
|
||||
log "Terra repository already configured and verified"
|
||||
return 0
|
||||
elif (( effective_status != 1 )); then
|
||||
log "Effective Terra repository configuration is not trusted"
|
||||
return "$TERRA_TRUST_FAILURE_STATUS"
|
||||
_terra_adoptable_status "$dump" || adoptable_status=$?
|
||||
if (( adoptable_status != 0 )); then
|
||||
log "Effective Terra repository configuration is not trusted"
|
||||
return "$TERRA_TRUST_FAILURE_STATUS"
|
||||
fi
|
||||
log "Adopting the existing Terra repository into Panama's reviewed form"
|
||||
adopt_terra_repository
|
||||
return $?
|
||||
fi
|
||||
if rpm -q terra-release >/dev/null 2>&1; then
|
||||
log "terra-release is installed without one trusted enabled Terra repository"
|
||||
@@ -1172,16 +1312,9 @@ if [[ "$ROLE" == server ]]; then
|
||||
fi
|
||||
|
||||
echo -e "\n--- Installing Repositories ---"
|
||||
log "Installing RPM Fusion Free and Nonfree Repositories"
|
||||
install_rpmfusion_repositories > /dev/null
|
||||
log "Enabling Fedora Cisco OpenH264 Repository"
|
||||
# soft: this repo does not exist on every spin, and its absence must not cost
|
||||
# the desktop -- the ordering rule at soft()'s definition applies to the
|
||||
# repository extras just as much as to the codec swaps below.
|
||||
soft "enabling the openh264 repository" sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1
|
||||
log "Installing RPM Fusion AppStream Metadata"
|
||||
soft "the core group update" sudo dnf update @core -y
|
||||
soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data
|
||||
# Terra goes first so a machine whose Terra is enabled but not yet in Panama's
|
||||
# reviewed form is adopted before any other transaction below runs against it.
|
||||
#
|
||||
# Terra bootstraps itself: --repofrompath defines a throwaway repo just long
|
||||
# enough to install terra-release, which then writes the real /etc/yum.repos.d
|
||||
# entry. Doing that a second time is not harmless -- dnf5 refuses the whole
|
||||
@@ -1195,6 +1328,17 @@ soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstr
|
||||
log "Installing Terra Repository"
|
||||
install_terra_repository > /dev/null
|
||||
|
||||
log "Installing RPM Fusion Free and Nonfree Repositories"
|
||||
install_rpmfusion_repositories > /dev/null
|
||||
log "Enabling Fedora Cisco OpenH264 Repository"
|
||||
# soft: this repo does not exist on every spin, and its absence must not cost
|
||||
# the desktop -- the ordering rule at soft()'s definition applies to the
|
||||
# repository extras just as much as to the codec swaps below.
|
||||
soft "enabling the openh264 repository" sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1
|
||||
log "Installing RPM Fusion AppStream Metadata"
|
||||
soft "the core group update" sudo dnf update @core -y
|
||||
soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data
|
||||
|
||||
echo -e "\n--- Installing relevant packages ---"
|
||||
log "Updating all packages. This may take a while"
|
||||
sudo dnf update -y --refresh > /dev/null
|
||||
@@ -1299,6 +1443,28 @@ if ! install_claude_desktop_if_trusted; then
|
||||
softly_failed+=("Claude Desktop")
|
||||
fi
|
||||
|
||||
# ChatGPT Desktop: OpenAI ships an official Linux RPM now. Panama used to build
|
||||
# a community wrapper from the macOS disk image -- it was `panama app
|
||||
# chatgpt-desktop` -- because no packaged form existed; that build froze often
|
||||
# and carried its own local rebuild daemon. The official package is strictly
|
||||
# better: it comes from a repository, so it upgrades with every other package
|
||||
# from then on.
|
||||
#
|
||||
# The repository and its signing key are established first, from the copy
|
||||
# pinned in setup/keys/, so dnf verifies the metadata and the package before
|
||||
# either reaches root. Upstream's own instructions do not allow that -- see
|
||||
# setup/lib/chatgpt-package for why they are not followed here.
|
||||
if rpm -q chatgpt >/dev/null 2>&1; then
|
||||
log "ChatGPT Desktop already installed"
|
||||
elif ! chatgpt_install_repository sudo; then
|
||||
log "Could not establish the verified ChatGPT repository; skipping"
|
||||
softly_failed+=("ChatGPT Desktop")
|
||||
else
|
||||
log "Installing ChatGPT Desktop..."
|
||||
sudo dnf install -y chatgpt > /dev/null \
|
||||
|| { log "ChatGPT Desktop install failed; skipping"; softly_failed+=("ChatGPT Desktop"); }
|
||||
fi
|
||||
|
||||
# The RPM ships rustdesk.service already enabled, which is what provides
|
||||
# unattended access; Panama deliberately does not start it a second time.
|
||||
install_rustdesk || true
|
||||
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/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."
|
||||
+25
-23
@@ -11,12 +11,11 @@
|
||||
# this repository, not anybody's personal content, so a stranger who clones
|
||||
# Panama wants it for exactly the same reason its author does.
|
||||
#
|
||||
# ~/.claude/skills was a single symlink into user/agents/skills until now, and
|
||||
# a directory cannot be two things at once. So the destination becomes a real
|
||||
# directory and every skill -- shipped here, personal from user/ -- is linked
|
||||
# into it one at a time. link-user runs after this stage on purpose: it links
|
||||
# last, so a personal skill named like a shipped one wins, which is the
|
||||
# precedence Claude Code itself uses.
|
||||
# ~/.agents/skills and ~/.claude/skills may each start as a single symlink into
|
||||
# user/agents/skills, but a directory cannot point at personal and shipped
|
||||
# skills at once. Both destinations become real directories with one link per
|
||||
# skill. link-user runs after this stage on purpose, so a personal skill named
|
||||
# like a shipped one wins in every agent runtime.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -25,7 +24,7 @@ log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
SKILLS_DIR="$PANAMA_PATH/skills"
|
||||
PANAMA_OLD="$PANAMA_PATH/config/old"
|
||||
DESTINATION="$HOME/.claude/skills"
|
||||
DESTINATIONS=("$HOME/.agents/skills" "$HOME/.claude/skills")
|
||||
|
||||
[[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; }
|
||||
|
||||
@@ -51,27 +50,30 @@ displace() {
|
||||
log "Moved existing $destination to $backup"
|
||||
}
|
||||
|
||||
# The destination itself has to be a real directory before anything can be
|
||||
# linked into it. An old whole-directory symlink is removed; a regular file
|
||||
# somebody left at this path is kept, in config/old/.
|
||||
mkdir -p "$(dirname "$DESTINATION")"
|
||||
if [[ -L "$DESTINATION" ]]; then
|
||||
rm -f "$DESTINATION"
|
||||
log "Removed the old $DESTINATION symlink; skills are linked one by one now"
|
||||
elif [[ -e "$DESTINATION" && ! -d "$DESTINATION" ]]; then
|
||||
displace "$DESTINATION"
|
||||
fi
|
||||
mkdir -p "$DESTINATION"
|
||||
# Each destination has to be a real directory before anything can be linked
|
||||
# into it. An old whole-directory symlink is removed; a regular file somebody
|
||||
# left at the path is kept in config/old/.
|
||||
for destination in "${DESTINATIONS[@]}"; do
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
if [[ -L "$destination" ]]; then
|
||||
rm -f "$destination"
|
||||
log "Removed the old $destination symlink; skills are linked one by one now"
|
||||
elif [[ -e "$destination" && ! -d "$destination" ]]; then
|
||||
displace "$destination"
|
||||
fi
|
||||
mkdir -p "$destination"
|
||||
done
|
||||
|
||||
linked=0
|
||||
for skill in "$SKILLS_DIR"/*; do
|
||||
[[ -e "$skill" ]] || continue
|
||||
name="$(basename "$skill")"
|
||||
target="$DESTINATION/$name"
|
||||
|
||||
displace "$target"
|
||||
ln -s "$skill" "$target"
|
||||
log "Linked skills/$name → $target"
|
||||
for destination in "${DESTINATIONS[@]}"; do
|
||||
target="$destination/$name"
|
||||
displace "$target"
|
||||
ln -s "$skill" "$target"
|
||||
log "Linked skills/$name → $target"
|
||||
done
|
||||
linked=$(( linked + 1 ))
|
||||
done
|
||||
|
||||
|
||||
@@ -85,9 +85,15 @@ if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then
|
||||
# alone takes long enough to be worth not repeating on every re-run of
|
||||
# a stage that is otherwise nearly instant.
|
||||
built="$vicinae_data_dir/extensions/$name"
|
||||
if [[ -d "$built" && "$extension/src" -ot "$built" ]]; then
|
||||
printf 'Vicinae extension %s is already built\n' "$name"
|
||||
continue
|
||||
if [[ -d "$built" ]]; then
|
||||
newer_source=''
|
||||
if newer_source="$(find "$extension/src" -type f -newer "$built" -print -quit)" \
|
||||
&& [[ -z "$newer_source" \
|
||||
&& ! "$extension/package.json" -nt "$built" \
|
||||
&& ! "$extension/package-lock.json" -nt "$built" ]]; then
|
||||
printf 'Vicinae extension %s is already built\n' "$name"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'Building Vicinae extension %s\n' "$name"
|
||||
|
||||
@@ -82,6 +82,7 @@ live-desktop tests/quickshell/dock-position-contract
|
||||
# Reads Hyprland option descriptions from the live compositor to verify enum
|
||||
# mappings.
|
||||
live-compositor tests/quickshell/enum-hypr-map-contract
|
||||
hermetic tests/quickshell/exclusion-idiom-contract
|
||||
hermetic tests/quickshell/fingerprint-contract
|
||||
# Reads the host firewall state through the production firewall helper.
|
||||
live-host tests/quickshell/firewall-contract
|
||||
@@ -247,6 +248,7 @@ hermetic tests/server/containers-shape-contract
|
||||
hermetic tests/server/panama-server-contract
|
||||
hermetic tests/setup/apps-contract
|
||||
hermetic tests/setup/boot-contract
|
||||
hermetic tests/setup/chatgpt-package-contract
|
||||
hermetic tests/setup/contract-manifest-contract
|
||||
hermetic tests/setup/crash-watch-contract
|
||||
hermetic tests/setup/desktop-first-contract
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/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,8 +32,6 @@ panama="$repo_dir/bin/panama"
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
|
||||
|
||||
shopt -s nullglob
|
||||
definitions=("$apps_dir"/*)
|
||||
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/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,16 +11,22 @@ manifest="$repo_dir/tests/contracts.manifest"
|
||||
|
||||
discover_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
|
||||
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||||
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
|
||||
done < <(find "$repo_dir/tests" -type f \
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||
}
|
||||
|
||||
validate_manifest() {
|
||||
local candidate="$1"
|
||||
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 -a capability_list=()
|
||||
local -A manifest_paths=() capability_counts=()
|
||||
|
||||
@@ -65,6 +65,12 @@ trap 'rm -rf "$work"' EXIT
|
||||
|
||||
filter="$(sed -n '/^packages_in()/,/^}/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" ]] || {
|
||||
printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2
|
||||
exit 1
|
||||
@@ -99,6 +105,7 @@ LIST
|
||||
source "$catalog"
|
||||
eval "$filter"
|
||||
eval "$loop"
|
||||
eval "$deps"
|
||||
install_extra_category "$fixture"
|
||||
)
|
||||
|
||||
@@ -132,6 +139,7 @@ printf 'flatpak:org.example.OnlyFlatpak\n' >"$flatpak_only"
|
||||
source "$catalog"
|
||||
eval "$filter"
|
||||
eval "$loop"
|
||||
eval "$deps"
|
||||
install_extra_category "$flatpak_only"
|
||||
)
|
||||
flatpak_only_status=$?
|
||||
@@ -149,6 +157,7 @@ grep -q 'flatpak install -y flathub org.example.OnlyFlatpak' <<<"$(cat "$calls"
|
||||
source "$catalog"
|
||||
eval "$filter"
|
||||
eval "$loop"
|
||||
eval "$deps"
|
||||
EXTRAS_DIR="$extras_dir"
|
||||
for extra in ${PANAMA_EXTRAS:-}; do
|
||||
[[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra"
|
||||
|
||||
@@ -123,7 +123,8 @@ 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'
|
||||
|
||||
# node_modules is a dependency tree, not configuration.
|
||||
git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \
|
||||
git -C "$repo_dir" check-ignore --no-index -q \
|
||||
"$extension/node_modules/package.json" 2>/dev/null \
|
||||
|| note 'the extension node_modules is not gitignored'
|
||||
|
||||
# npm must honour the committed dependency graph. This disposable fixture
|
||||
@@ -167,6 +168,66 @@ stage_output="$(PATH="$fixture_root/bin:$PATH" PANAMA_PATH="$fixture_root" \
|
||||
cmp -s -- "$lock_before" "$lockfile" \
|
||||
|| note 'a rejected Vicinae lockfile mismatch changed package-lock.json'
|
||||
|
||||
# Editing an existing source file does not change its parent directory's
|
||||
# timestamp, so freshness must inspect files rather than the src directory.
|
||||
freshness_root="$fixture_root/freshness"
|
||||
mkdir -p "$freshness_root/config/local/share/vicinae/scripts" \
|
||||
"$freshness_root/config/local/share/vicinae/extensions/panama-search/src" \
|
||||
"$freshness_root/bin"
|
||||
freshness_extension="$freshness_root/config/local/share/vicinae/extensions/panama-search"
|
||||
cp -- "$manifest" "$freshness_extension/package.json"
|
||||
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/package-lock.json" \
|
||||
"$freshness_extension/package-lock.json"
|
||||
cp -- "$extension/src/search.tsx" "$freshness_extension/src/search.tsx"
|
||||
cat >"$freshness_root/bin/npm" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >>"${NPM_LOG:?}"
|
||||
if [[ "${1:-}" == ci ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-} ${2:-}" == 'run build' ]]; then
|
||||
built="${VICINAE_DATA_DIR:?}/extensions/$(basename "$PWD")"
|
||||
mkdir -p "$built"
|
||||
touch "$built"
|
||||
exit 0
|
||||
fi
|
||||
exit 64
|
||||
EOF
|
||||
chmod +x "$freshness_root/bin/npm"
|
||||
: >"$freshness_root/npm.log"
|
||||
run_freshness_stage() {
|
||||
PATH="$freshness_root/bin:$PATH" PANAMA_PATH="$freshness_root" \
|
||||
VICINAE_DATA_DIR="$freshness_root/vicinae-data" \
|
||||
NPM_LOG="$freshness_root/npm.log" bash "$stage" >/dev/null 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"
|
||||
touch -r "$built_extension" "$freshness_extension/src/search.tsx"
|
||||
touch -d '2031-01-01 UTC' "$freshness_extension/package.json"
|
||||
run_freshness_stage || note 'the manifest-changed Vicinae freshness fixture failed'
|
||||
manifest_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
||||
[[ "$manifest_changed_builds" -eq $(( source_changed_builds + 1 )) ]] \
|
||||
|| note 'changing a Vicinae package.json did not trigger a rebuild'
|
||||
|
||||
touch -r "$built_extension" "$freshness_extension/package.json" \
|
||||
"$freshness_extension/src/search.tsx"
|
||||
touch -d '2032-01-01 UTC' "$freshness_extension/package-lock.json"
|
||||
run_freshness_stage || note 'the lockfile-changed Vicinae freshness fixture failed'
|
||||
lockfile_changed_builds="$(grep -c '^run build$' "$freshness_root/npm.log")"
|
||||
[[ "$lockfile_changed_builds" -eq $(( manifest_changed_builds + 1 )) ]] \
|
||||
|| note 'changing a Vicinae package-lock.json did not trigger a rebuild'
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
|
||||
@@ -164,6 +164,31 @@ expect_failure key_fingerprint_matches "$fixtures/fixture-key.asc" '000000000000
|
||||
cat "$fixtures/fixture-key.asc" "$fixtures/wrong-signer-key.asc" > "$test_tmp/combined-key.asc"
|
||||
expect_failure key_fingerprint_matches "$test_tmp/combined-key.asc" "$fixture_fingerprint"
|
||||
|
||||
# A parser must not accept plausible output from a GPG process that failed.
|
||||
# The later import and verify calls succeed so both public helpers depend on
|
||||
# the show-only producer's status rather than failing for an unrelated reason.
|
||||
producer_failure_bin="$test_tmp/gpg-producer-failure-bin"
|
||||
mkdir "$producer_failure_bin"
|
||||
cat > "$producer_failure_bin/gpg" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
if [[ " \$* " == *' --import-options show-only '* ]]; then
|
||||
printf 'pub:::::::::\n'
|
||||
printf 'fpr:::::::::$fixture_fingerprint:\n'
|
||||
exit 42
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$producer_failure_bin/gpg"
|
||||
expect_failure env PATH="$producer_failure_bin:$PATH" bash -c '
|
||||
source "$1"
|
||||
key_fingerprint_matches "$2" "$3"
|
||||
' _ "$repo_dir/setup/lib/artifact-provenance" "$fixtures/fixture-key.asc" "$fixture_fingerprint"
|
||||
expect_failure env PATH="$producer_failure_bin:$PATH" bash -c '
|
||||
source "$1"
|
||||
verify_detached_signature "$2" "$3" "$4"
|
||||
' _ "$repo_dir/setup/lib/artifact-provenance" "$fixtures/fixture-key.asc" \
|
||||
"$fixtures/SHASUMS256.txt.asc" "$fixtures/SHASUMS256.txt"
|
||||
|
||||
expect_success verify_detached_signature \
|
||||
"$fixtures/fixture-key.asc" "$fixtures/SHASUMS256.txt.asc" "$fixtures/SHASUMS256.txt"
|
||||
expect_failure verify_detached_signature \
|
||||
@@ -336,8 +361,9 @@ expect_failure load_installer_provenance "$parser_fixture"
|
||||
installer_fixture="$test_tmp/installer-fixture"
|
||||
mkdir -p "$installer_fixture/setup/lib" "$installer_fixture/setup/provenance/keys" \
|
||||
"$installer_fixture/setup/scripts"
|
||||
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/extras-catalog" \
|
||||
"$repo_dir/setup/lib/machine-role" "$installer_fixture/setup/lib/"
|
||||
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/chatgpt-package" \
|
||||
"$repo_dir/setup/lib/extras-catalog" "$repo_dir/setup/lib/machine-role" \
|
||||
"$installer_fixture/setup/lib/"
|
||||
cp "$config" "$installer_fixture/setup/provenance/installers.conf"
|
||||
cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/"
|
||||
sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \
|
||||
@@ -868,7 +894,16 @@ if [[ -n "$query" ]]; then
|
||||
trusted|wrong-key) mode=trusted ;;
|
||||
nogpg) mode=legacy ;;
|
||||
wrong-url) mode=override-url ;;
|
||||
stock|stock-wrong-key) mode=stock ;;
|
||||
esac
|
||||
# Adoption rewrites the repository file. Once it is the pinned form the
|
||||
# dump has to say so, or the re-verification adoption performs on itself
|
||||
# could never pass.
|
||||
if [[ "$mode" == stock && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \
|
||||
&& grep -q '^gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama$' \
|
||||
"$STUB_ETC/yum.repos.d/terra.repo"; then
|
||||
mode=trusted
|
||||
fi
|
||||
if [[ "$mode" == auto && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \
|
||||
&& grep -q '^baseurl=https://repos.fyralabs.com/terra44$' "$STUB_ETC/yum.repos.d/terra.repo"; then
|
||||
mode=trusted
|
||||
@@ -900,6 +935,13 @@ if [[ -n "$query" ]]; then
|
||||
printf 'metalink = https://tetsudou.fyralabs.com/terra44\nmirrorlist = \n'
|
||||
printf 'pkg_gpgcheck = 0\nrepo_gpgcheck = 0\n'
|
||||
;;
|
||||
stock)
|
||||
printf '======== "terra" repository configuration: ========\n'
|
||||
printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n'
|
||||
printf 'gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\n'
|
||||
printf 'metalink = https://tetsudou.fyralabs.com/metalink?repo=terra44&arch=x86_64\n'
|
||||
printf 'mirrorlist = \npkg_gpgcheck = 1\nrepo_gpgcheck = 1\n'
|
||||
;;
|
||||
override-url)
|
||||
printf '======== "terra" repository configuration: ========\n'
|
||||
printf 'baseurl = https://evil.invalid/terra44\nenabled = 1\ngpgcheck = 1\n'
|
||||
@@ -1094,6 +1136,17 @@ run_installer_function() {
|
||||
printf '[terra]\nbaseurl=https://evil.invalid/terra44\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama\n' \
|
||||
> "$case_root/etc/yum.repos.d/terra.repo"
|
||||
;;
|
||||
stock|stock-wrong-key)
|
||||
if [[ "${STUB_TERRA_REPO_MODE}" == stock ]]; then
|
||||
cp "$installer_fixture/setup/provenance/keys/terra44.asc" \
|
||||
"$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44"
|
||||
else
|
||||
cp "$installer_fixture/setup/provenance/keys/flathub.asc" \
|
||||
"$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44"
|
||||
fi
|
||||
printf '[terra]\nmetalink=https://tetsudou.fyralabs.com/metalink?repo=terra44&arch=$basearch\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\n' \
|
||||
> "$case_root/etc/yum.repos.d/terra.repo"
|
||||
;;
|
||||
wrong-key)
|
||||
cp "$installer_fixture/setup/provenance/keys/flathub.asc" \
|
||||
"$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama"
|
||||
@@ -1744,6 +1797,39 @@ for mode in nogpg wrong-url wrong-key absent; do
|
||||
|| fail "untrusted existing Terra $mode state reached a mutation"
|
||||
done
|
||||
|
||||
# A machine that installed Terra the way Terra documents it. The repository file
|
||||
# is terra-release's own -- a metalink, and the key at its stock path -- so it is
|
||||
# not Panama's pinned form, but it IS the fingerprint this repository reviewed,
|
||||
# with every signature check on. That is an adoption, not a compromise.
|
||||
#
|
||||
# Refusing it was a gate with no door: the ordinary Fedora desktop could never
|
||||
# reach the pinned state, and status 78 then stopped every stage of every run,
|
||||
# including the ones that never open DNF.
|
||||
reset_installer_fixture
|
||||
STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \
|
||||
expect_success run_installer_function terra-stock-preflight preflight_terra_trust
|
||||
|
||||
reset_installer_fixture
|
||||
STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \
|
||||
expect_success run_installer_function terra-stock-adopt install_terra_repository
|
||||
terra_adopted="$test_tmp/cases/terra-stock-adopt/etc/yum.repos.d/terra.repo"
|
||||
grep -qx 'baseurl=https://repos.fyralabs.com/terra44' "$terra_adopted" \
|
||||
|| fail 'adoption left Terra off the reviewed baseurl'
|
||||
grep -qx 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' "$terra_adopted" \
|
||||
|| fail 'adoption left Terra off the reviewed key path'
|
||||
grep -q 'metalink' "$terra_adopted" \
|
||||
&& fail 'adoption kept the metalink it was supposed to replace'
|
||||
[[ "$(<"$test_tmp/cases/terra-stock-adopt/commands.log")" != *'dnf:install'* ]] \
|
||||
|| fail 'adoption opened a DNF transaction it does not need'
|
||||
|
||||
# Adoption is anchored on the fingerprint, not the URL. The same stock shape
|
||||
# verifying against a key that is not Terra's is still a hard refusal.
|
||||
reset_installer_fixture
|
||||
STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock-wrong-key \
|
||||
expect_failure run_installer_function terra-stock-wrong-key install_terra_repository
|
||||
[[ "$(<"$test_tmp/cases/terra-stock-wrong-key/commands.log")" != *'sudo:'* ]] \
|
||||
|| fail 'a stock Terra signed by an unreviewed key reached a mutation'
|
||||
|
||||
# An optional security field may be absent, but duplicates are malformed even
|
||||
# when one copy looks safe. These cases catch the absent/duplicate conflation.
|
||||
for duplicate_case in \
|
||||
|
||||
+41
-20
@@ -2,7 +2,7 @@
|
||||
|
||||
# The manual this machine hands an agent.
|
||||
#
|
||||
# skills/ and .claude/skills/panama exist because an agent asked to do anything
|
||||
# skills/ and .agents/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
|
||||
# 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
|
||||
@@ -16,7 +16,7 @@
|
||||
# That is a convention on the prose -- name things exactly, in backticks --
|
||||
# and it is how they should be written anyway.
|
||||
# 3. The delivery works: link-skills is a stage, in the right place, and the
|
||||
# personal manifest hands ~/.claude/skills over to the linkdir kind.
|
||||
# personal manifest hands both shared skill homes to the linkdir kind.
|
||||
#
|
||||
# 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.
|
||||
@@ -32,9 +32,21 @@ manifest="$repo_dir/user/manifest"
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
# The three, and where each is delivered from. The two under skills/ are shipped
|
||||
# to every machine; the third is project-level and needs no delivery at all.
|
||||
SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama)
|
||||
# Every directory under skills/ ships to every machine. The project-level
|
||||
# panama skill stays in this repository and needs no home-directory delivery.
|
||||
SKILL_DIRS=()
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,7 +66,7 @@ for relative in "${SKILL_DIRS[@]}"; do
|
||||
present+=("$directory")
|
||||
|
||||
# Frontmatter is the first --- delimited block, and a skill without one is
|
||||
# not a skill: Claude Code skips the directory entirely.
|
||||
# not a skill: agent loaders skip the directory entirely.
|
||||
frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
|
||||
if [[ -z "$frontmatter" ]]; then
|
||||
note "$relative/SKILL.md does not open with a --- frontmatter block"
|
||||
@@ -207,12 +219,12 @@ else
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# A checkout and a home of its own. Never the real ones: ~/.claude/skills on
|
||||
# this machine is somebody's live agent setup, and a contract that broke it
|
||||
# mid-session would be worse than the bug it was looking for.
|
||||
# A checkout and a home of its own. Never the real ones: these are somebody's
|
||||
# live agent setup, and a contract that broke them mid-session would be worse
|
||||
# than the bug it was looking for.
|
||||
checkout="$work/Panama"
|
||||
home="$work/home"
|
||||
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude"
|
||||
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents"
|
||||
cp "$linker" "$checkout/setup/scripts/link-skills"
|
||||
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
|
||||
|
||||
@@ -220,6 +232,7 @@ else
|
||||
# symlink, which is what link-user used to leave here.
|
||||
mkdir -p "$work/personal"
|
||||
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; }
|
||||
|
||||
@@ -231,22 +244,30 @@ else
|
||||
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
|
||||
[[ -L "$home/.claude/skills/shipped" ]] \
|
||||
|| 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" \
|
||||
|| 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
|
||||
# config/old rather than being deleted, the same promise the other stages
|
||||
# make. A symlink is not, and must not accumulate there.
|
||||
rm "$home/.claude/skills/shipped"
|
||||
mkdir -p "$home/.claude/skills/shipped"
|
||||
printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md"
|
||||
mkdir -p "$home/.claude/skills/untouched"
|
||||
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||
rm -f "$skill_home/shipped"
|
||||
mkdir -p "$skill_home/shipped"
|
||||
printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md"
|
||||
mkdir -p "$skill_home/untouched"
|
||||
done
|
||||
|
||||
run
|
||||
grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \
|
||||
|| note 'link-skills destroyed a real skill instead of moving it to config/old'
|
||||
[[ -d "$home/.claude/skills/untouched" ]] \
|
||||
|| note 'link-skills removed a skill it does not ship'
|
||||
[[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \
|
||||
|| note 'link-skills did not preserve real skills from both agent homes'
|
||||
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||
[[ -d "$skill_home/untouched" ]] \
|
||||
|| note "link-skills removed an unshipped skill from $skill_home"
|
||||
done
|
||||
|
||||
before="$(find "$checkout/config/old" | wc -l)"
|
||||
run
|
||||
@@ -287,8 +308,8 @@ fi
|
||||
|
||||
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'
|
||||
grep -qE '^\s*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
||||
|| note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path'
|
||||
grep -qE '^\s*linkdir\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'
|
||||
grep -q 'linkdir)' "$user_linker" \
|
||||
|| note 'link-user does not implement the linkdir kind the manifest asks for'
|
||||
grep -q 'linkdir' "$repo_dir/user/README.md" \
|
||||
|
||||
@@ -39,6 +39,12 @@ trap 'rm -rf "$tmp"' EXIT
|
||||
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
||||
link-vicinae-scripts setup-server link-server setup-identity
|
||||
install-hardware)
|
||||
PACKAGE_BEHAVIOR_INPUTS=(
|
||||
setup/lib/artifact-provenance
|
||||
setup/lib/chatgpt-package
|
||||
setup/lib/extras-catalog
|
||||
setup/lib/machine-role
|
||||
)
|
||||
|
||||
copy_hash_inputs() {
|
||||
local root="$1" source relative
|
||||
@@ -50,8 +56,10 @@ copy_hash_inputs() {
|
||||
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
|
||||
find "$repo_dir/setup/provenance" -type f -print0
|
||||
)
|
||||
mkdir -p "$root/setup/lib"
|
||||
cp -- "$repo_dir/setup/lib/artifact-provenance" "$root/setup/lib/artifact-provenance"
|
||||
for relative in "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
||||
mkdir -p "$(dirname "$root/$relative")"
|
||||
cp -- "$repo_dir/$relative" "$root/$relative"
|
||||
done
|
||||
}
|
||||
|
||||
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
||||
@@ -255,7 +263,7 @@ grep -qx 'install-packages' <<<"$ran_forced" \
|
||||
# Dynamically discovering them makes this fail when a new reviewed input is
|
||||
# added but omitted from hash_packages.
|
||||
for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \
|
||||
'setup/scripts/install-packages' 'setup/lib/artifact-provenance'; do
|
||||
'setup/scripts/install-packages' "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
||||
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
|
||||
install_status=0
|
||||
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
||||
@@ -282,7 +290,7 @@ done
|
||||
# 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
|
||||
# hidden by the final digest command.
|
||||
for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance; do
|
||||
for fixed_input in setup/scripts/install-packages "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
||||
for case_name in missing directory symlink unreadable; do
|
||||
case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
|
||||
build_fixture "$case_root"
|
||||
@@ -320,6 +328,26 @@ grep -qx 'install-packages' <<<"$ran_hash_failure" \
|
||||
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'
|
||||
|
||||
# Inputs changed while install-packages was running were not the inputs it
|
||||
# consumed at the start. Do not stamp the later bytes as successfully applied.
|
||||
build_fixture "$tmp/hash-mid-stage-drift"
|
||||
cat >"$tmp/hash-mid-stage-drift/setup/scripts/install-packages" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${1:-}" == --trust-preflight ]]; then
|
||||
printf 'trust-preflight\n' >>"$PANAMA_RAN"
|
||||
exit 0
|
||||
fi
|
||||
printf 'install-packages\n' >>"$PANAMA_RAN"
|
||||
printf '\nchanged during package installation\n' >>"$PANAMA_PATH/setup/lib/machine-role"
|
||||
EOF
|
||||
chmod +x "$tmp/hash-mid-stage-drift/setup/scripts/install-packages"
|
||||
install_status=0
|
||||
run_install "$tmp/hash-mid-stage-drift" --upgrade >/dev/null || install_status=$?
|
||||
[[ "$install_status" -ne 0 ]] \
|
||||
|| note 'mid-stage package input drift returned success'
|
||||
[[ ! -e "$tmp/hash-mid-stage-drift/state/panama/packages-hash" ]] \
|
||||
|| note 'mid-stage package input drift stamped bytes the stage did not start with'
|
||||
|
||||
# A failing stage must not record the hash, or the failure is hidden forever.
|
||||
build_fixture "$tmp/c" 1
|
||||
install_status=0
|
||||
@@ -332,30 +360,48 @@ fi
|
||||
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
||||
|| note 'an ordinary package-stage failure no longer allows later safe stages'
|
||||
|
||||
# An invalid enabled Terra root is not an ordinary package failure. It must
|
||||
# stop before the installer's bootstrap DNF and before every stage.
|
||||
# An untrusted Terra root is not an ordinary package failure, and it is not a
|
||||
# reason to abandon the machine either. It suppresses the stages that open DNF
|
||||
# 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
|
||||
install_status=0
|
||||
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
|
||||
[[ "$install_status" -eq 78 ]] \
|
||||
|| note "initial Terra trust failure returned $install_status instead of 78"
|
||||
asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")"
|
||||
[[ "$asserted_preflight" == trust-preflight ]] \
|
||||
|| note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}"
|
||||
grep -qx 'trust-preflight' "$tmp/terra-preflight-hard/ran" \
|
||||
|| note 'initial Terra trust fixture never reached the preflight'
|
||||
assert_trust_refusal "$tmp/terra-preflight-hard" 'initial Terra trust failure'
|
||||
|
||||
# The trust verifier is itself mandatory. Losing its executable adapter must
|
||||
# fail closed before interview, bootstrap, or stage work.
|
||||
# The trust verifier is itself mandatory. Losing its executable adapter refuses
|
||||
# package work exactly as a failing verdict does, rather than being ignored.
|
||||
build_fixture "$tmp/terra-preflight-missing"
|
||||
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
|
||||
install_status=0
|
||||
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
|
||||
[[ "$install_status" -eq 78 ]] \
|
||||
|| note "missing Terra trust verifier returned $install_status instead of 78"
|
||||
[[ ! -s "$tmp/terra-preflight-missing/ran" ]] \
|
||||
|| note 'missing Terra trust verifier allowed later work'
|
||||
assert_trust_refusal "$tmp/terra-preflight-missing" 'missing Terra trust verifier'
|
||||
|
||||
# The package stage repeats the preflight to close a configuration-change race.
|
||||
# Its hard status must also stop link stages and install-hardware immediately.
|
||||
# Its hard status suppresses the DNF stages that would have followed it.
|
||||
build_fixture "$tmp/terra-stage-hard" 78 0
|
||||
install_status=0
|
||||
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
||||
@@ -363,10 +409,16 @@ run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
||||
|| note "stage-time Terra trust failure returned $install_status instead of 78"
|
||||
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|
||||
|| note 'stage-time Terra trust fixture never reached install-packages'
|
||||
for suppressed in link-dotfiles link-skills link-user change-settings install-hardware dnf-transaction; do
|
||||
for suppressed in change-settings install-hardware; do
|
||||
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
|
||||
&& note "stage-time Terra trust failure still ran $suppressed"
|
||||
done
|
||||
grep -q '^migrate ' "$tmp/terra-stage-hard/ran" \
|
||||
&& note 'stage-time Terra trust failure still ran migrations'
|
||||
for safe in "${SAFE_STAGES[@]}"; do
|
||||
grep -qx "$safe" "$tmp/terra-stage-hard/ran" \
|
||||
|| note "stage-time Terra trust failure suppressed $safe"
|
||||
done
|
||||
|
||||
# A full install always runs the stage, whatever any recorded hash says.
|
||||
build_fixture "$tmp/d"
|
||||
|
||||
@@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE'
|
||||
# a comment, and a blank line follow
|
||||
|
||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||
link agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.claude/skills
|
||||
copy plain.txt ~/.config/plain.txt
|
||||
link missing.txt ~/.config/missing.txt
|
||||
@@ -102,11 +102,12 @@ PANAMA_USER_CONTENT=no run
|
||||
|
||||
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
|
||||
|
||||
# ~/.claude/skills is shared now: link-skills has already made it a real
|
||||
# directory and linked Panama's own skills into it. A linkdir entry has to land
|
||||
# beside those rather than replace the directory holding them.
|
||||
mkdir -p "$home/.claude/skills"
|
||||
# Both skill homes are shared now: link-skills has already made them real
|
||||
# directories and linked Panama's own skills into each. Personal linkdir
|
||||
# entries land beside those rather than replacing either directory.
|
||||
mkdir -p "$home/.claude/skills" "$home/.agents/skills"
|
||||
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
|
||||
ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped"
|
||||
|
||||
PANAMA_USER_CONTENT=yes run
|
||||
|
||||
@@ -114,8 +115,12 @@ PANAMA_USER_CONTENT=yes run
|
||||
|| note 'CLAUDE.md was not replaced with a symlink into the checkout'
|
||||
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|
||||
|| note 'the CLAUDE.md link does not resolve to the tracked file'
|
||||
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||
|| note 'the skills directory was not linked as a directory'
|
||||
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
||||
|| note 'a linkdir entry replaced ~/.agents/skills with a symlink'
|
||||
[[ -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" ]] \
|
||||
|| note 'a linkdir entry replaced its destination directory with a symlink'
|
||||
[[ -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 |
|
||||
| --- | --- | --- |
|
||||
| `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` | A skill installed on any machine lands in the checkout. The Claude path is `linkdir` — see below. |
|
||||
| `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/rules/` | `~/.claude/rules` | |
|
||||
| `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. |
|
||||
@@ -27,22 +27,22 @@ destination is empty. `linkdir` is the third, and it exists because one
|
||||
destination is no longer only ours.
|
||||
|
||||
Panama ships its own agent skills now (`skills/`, linked by
|
||||
`setup/scripts/link-skills`), and they go to `~/.claude/skills` — the same
|
||||
directory the personal ones went to as a single symlink. A directory cannot be
|
||||
a symlink to two places, so that entry became `linkdir`: the destination is a
|
||||
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.
|
||||
`setup/scripts/link-skills`), and they go to both skill homes. A directory
|
||||
cannot be a symlink to two places, so both personal entries use `linkdir`: each
|
||||
destination is a real directory, and each child of `user/agents/skills/` is
|
||||
linked into it individually.
|
||||
|
||||
Two consequences, recorded rather than fixed:
|
||||
|
||||
- **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.
|
||||
That is the intent, and it is the precedence Claude Code uses anyway.
|
||||
after `link-skills` and displaces what it finds, so the personal one wins in
|
||||
both skill homes.
|
||||
- **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
|
||||
child that did not exist when they were made. Run `panama update` or
|
||||
`setup/scripts/link-user` after adding one.
|
||||
child that did not exist when they were made. Put new shared skills in
|
||||
`user/agents/skills/`, then run `panama update` or `setup/scripts/link-user`.
|
||||
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
|
||||
|
||||
|
||||
@@ -62,6 +62,40 @@ 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
|
||||
|
||||
- Make sure titles follow conventions from the repo.
|
||||
@@ -84,5 +118,8 @@ I wanted to share some of my preferences here so we can be more aligned as we wo
|
||||
- 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.
|
||||
- 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
|
||||
- Kelson - Owner of Ksense
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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
|
||||
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.
|
||||
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.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
@@ -108,6 +108,63 @@ 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
|
||||
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
|
||||
|
||||
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
|
||||
@@ -226,15 +283,19 @@ method for producing accurate screenshots, not part of the deliverable's story.
|
||||
Process, per deliverable:
|
||||
|
||||
1. Write `<slug>.md`.
|
||||
2. Write `<slug>.typ` from it, per the styling notes above.
|
||||
3. Compile: `typst compile <slug>.typ <slug>.pdf`. `typst` should already be on PATH;
|
||||
2. Run the prose bar over `<slug>.md` before building anything from it. A spike
|
||||
deliverable is the most prose-heavy thing this skill produces and it goes straight to
|
||||
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.
|
||||
4. Actually look at the compiled PDF using the Read tool (it reads PDFs directly, page
|
||||
5. 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
|
||||
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
|
||||
only means valid Typst, not that it looks right — actually look.
|
||||
5. If anything looks wrong, fix `<slug>.typ` and go back to step 3. Repeat until the
|
||||
6. If anything looks wrong, fix `<slug>.typ` and go back to step 4. Repeat until the
|
||||
PDF genuinely looks right, not just until it compiles without erroring.
|
||||
|
||||
If `.claude/docs/` is tracked by git in this repo (check with
|
||||
@@ -372,7 +433,7 @@ place from the start.
|
||||
- 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
|
||||
already exist on the ticket branch — pick up from the first unchecked step)
|
||||
- Run `pre-mr-review` now (implementation looks done, just need the audit + MR doc)
|
||||
- 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)
|
||||
Route to **Phase 1** or **Phase 2** accordingly.
|
||||
- **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
|
||||
@@ -620,8 +681,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
|
||||
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
|
||||
the handoff. Review first, fix, commit, and only then run `pre-mr-review` over the
|
||||
final tree.
|
||||
the handoff. Review first, fix, commit, and only then have the user
|
||||
run `pre-mr-review` over the final tree.
|
||||
|
||||
- **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
|
||||
@@ -671,11 +732,25 @@ 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
|
||||
local verification are done but this repo has no `pre-mr-review` skill to run, and
|
||||
let them decide how to proceed.
|
||||
7. Invoke the `pre-mr-review` skill. Read its verdict.
|
||||
- **Ready to Open MR**: continue to step 8.
|
||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its
|
||||
own commit), then invoke `pre-mr-review` again. Repeat until the verdict is Ready
|
||||
to Open MR. Don't write `mr.md` before that verdict is reached.
|
||||
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
|
||||
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.
|
||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix
|
||||
as its own commit), then ask the user to rerun the review from the work
|
||||
account and wait again. Repeat until the verdict is Ready to Open MR. Don't
|
||||
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,
|
||||
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`
|
||||
@@ -707,9 +782,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:
|
||||
|
||||
- Never hand-write a handoff section, and never edit one in place. If it is
|
||||
wrong, thin, or stale, fix the handoff file by rerunning `/pre-mr-review`,
|
||||
then re-paste.
|
||||
- On EVERY rerun, replace the whole existing handoff block with the whole
|
||||
wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work
|
||||
account to regenerate the handoff file, then re-paste.
|
||||
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole
|
||||
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
|
||||
reads stops being the version the audit actually produced.
|
||||
@@ -742,13 +817,10 @@ 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
|
||||
has grown past roughly 120 lines including the handoff, it has drifted.
|
||||
|
||||
Before saving, call the Skill tool with "unslop" and apply its pattern detection to
|
||||
the Summary and Additional Notes ONLY: puffery, superficial -ing phrases, "not just
|
||||
X but Y", vague attributions, rule-of-three padding. The pasted handoff is generated
|
||||
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.
|
||||
Before saving, run the prose bar (see **The prose bar** above) over the Summary and
|
||||
Additional Notes ONLY. The pasted handoff is generated output and is never edited.
|
||||
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:
|
||||
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
||||
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
||||
@@ -757,11 +829,9 @@ 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
|
||||
a field to a type that doesn't carry it.
|
||||
|
||||
**Same prose bar as `mr.md`.** Every field authored here is read by the Lead and the
|
||||
PM. Run the `unslop` skill's pattern detection over the markdown before rendering it
|
||||
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.
|
||||
**Run the prose bar** (see **The prose bar** above) over every field before rendering
|
||||
it to ADF, proof and mitigation cells included. Everything here is read by the Lead
|
||||
and the PM.
|
||||
|
||||
**Proof first.** Before filling any proof column, capture working feature proof
|
||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
||||
@@ -821,13 +891,12 @@ 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
|
||||
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_10142` Working Feature Proof: ALWAYS fill this on a Story, never
|
||||
leave it empty. It renders as its own panel in the ticket's testing section, so
|
||||
an empty field reads as unfilled testing even when every test-table cell is
|
||||
complete (this happened on KACP-23143). At minimum it lists the files staged in
|
||||
`proof/` for Gib to attach, one line each saying what the artifact shows, plus a
|
||||
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.
|
||||
- `customfield_10142` Working Feature Proof: leave it alone. Write nothing here.
|
||||
This field is where Gib drops the proof attachments, and dragging files into it
|
||||
replaces whatever text is sitting there, so a written list is destroyed by the
|
||||
upload it was describing. Naming each artifact is already the job of the Test
|
||||
Cases proof cells, which reference it by filename. Step 10 tells Gib which files
|
||||
to upload. Do not PUT this field on a Story.
|
||||
|
||||
**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
|
||||
|
||||
@@ -16,7 +16,9 @@ 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
|
||||
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 belongs in that repo, at `<repo>/.claude/skills/`.
|
||||
A skill that only makes sense inside one repo lives once at
|
||||
`<repo>/.agents/skills/<name>/`. Point Claude Code at that source with a
|
||||
`<repo>/.claude/skills/<name>` symlink.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
|
||||
+6
-7
@@ -28,14 +28,13 @@
|
||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||
link agents/AGENTS.md ~/.codex/AGENTS.md
|
||||
|
||||
# Skills, at both paths that look for them. Linked so that a skill installed by
|
||||
# any tool lands in the checkout and `panama update` offers to commit it.
|
||||
# Skills, at both paths that look for them. Each tracked skill is linked from
|
||||
# this checkout so edits stay shared and `panama update` offers to commit them.
|
||||
#
|
||||
# ~/.agents/skills is this directory and nothing else, so it stays one link.
|
||||
# ~/.claude/skills also holds the skills Panama itself ships, put there by the
|
||||
# link-skills stage, so it is a real directory with one link per skill -- and
|
||||
# this stage runs after that one, so a personal skill wins a name collision.
|
||||
link agents/skills ~/.agents/skills
|
||||
# Both destinations also hold skills Panama itself ships, put there by the
|
||||
# link-skills stage, so both are real directories with one link per skill. This
|
||||
# stage runs after that one, so a personal skill wins a name collision.
|
||||
linkdir agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.claude/skills
|
||||
|
||||
link agents/rules ~/.claude/rules
|
||||
|
||||
Reference in New Issue
Block a user