Author SHA1 Message Date
Gabriel Brown 85160ffef2 Stop writing text into the Working Feature Proof field
Dragging attachments into customfield_10142 replaces whatever text is
sitting there, so the list the skill wrote was 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, and step 10 tells me which
files to upload.

Only the Story rule changes. On a Bug that field carries the test-case
table rather than prose.
2026-09-14 14:36:54 -04:00
Gabriel Brown 59192143c5 Docs: Add the prose bar and record Josh as the APSCA lead
Anything a person other than me reads now gets an unslop pattern pass
before it ships: MR descriptions, ticket fields, deliverables. The rules
that matter most are the ones a generic pass misses. Write as me in first
person, never name me or use a stand-in, and never flag one of my own
decisions as a deviation, because that reads as my own AI tattling on me
and costs the PM time investigating a non-issue.

Punctuation is stricter than unslop's own pattern: no em dashes, en
dashes, semicolons or arrow glyphs. Generated output is never rewritten,
only regenerated.

Also records Josh as the tech lead on apsca_next, whose dev reviews carry
draft code and schema and are a starting point rather than a finished spec.
2026-09-14 14:36:52 -04:00
Gabriel Brown 2f2e3b88e8 Register MCP servers through the installer
An MCP server is a URL plus a bearer token, and the token is why this is a
stage rather than a manifest line. Panama is public, so the tokens cannot
live in it, and neither runtime keeps its server list in a file worth
symlinking: Codex writes them into config.toml beside dozens of unrelated
settings, and Claude Code into ~/.claude.json. link-mcp registers them
through the runtime's own CLI instead.

user/agents/mcp/servers is tracked and names which variable carries each
token. user/agents/mcp/env holds the tokens and is ignored. A new machine
gets the servers by dropping its own env file beside the tracked one.

Only Claude Code is handled, and only where the interview enabled personal
content. Rewriting a section of somebody's live Codex TOML is a worse
failure mode than leaving two lines to paste once.
2026-09-14 14:36:44 -04:00
Gabriel Brown 9156a7e05c Fix: Scan for DDC displays when quick settings opens
The scan was keyed off this item becoming visible, but on a machine with no
backlight `visible` stays false until a scan has already found a DDC
display, so it never scanned at all. It now keys off the quick settings
panel opening, which still keeps the second-long I2C probe off shell
startup.
2026-09-14 14:36:39 -04:00
Gabriel Brown 39a2e8e9ca Fix: Stop the display link retraining and blanking
This panel's DP link is marginal. 4500x3000@60 at 10bpc is around 24 Gbps,
right at the edge of DP 1.4 HBR3 and reliant on DSC, so every modeset
retrains the link and blanks the screen. 8bpc keeps headroom.

Steam games are the other trigger. Everything Hyprland does only for a real
fullscreen window (direct scanout, VRR, tearing, auto HDR) forces that
retrain, so steam_app windows get fullscreen_state "1 2": maximized
internally while the game believes it is fullscreen, which is what
borderless windowed looks like from the game's side.

Recorded alongside the related Panama settings already at 0,
directScanoutPolicy and vrrPolicy.
2026-09-14 14:36:35 -04:00
Gabriel Brown 6206565d95 Fix: Keep the screenshot picker out of its own capture
The picker paints an opaque frozen frame edge to edge, so the blur and dim
it inherited from the shared qs-overlay rule were compositor work on pixels
nobody sees. It now has its own layer rule.

no_anim is the load-bearing part. Capture.qml unmaps the picker and runs
grim 90ms later, and the default 200ms layersOut fade put the half-faded
picker, dimming and all, into every screenshot.
2026-09-14 14:36:33 -04:00
Gabriel Brown cceb7a707b Fix: Never pair exclusionMode with exclusiveZone
Quickshell's exclusiveZone setter flips exclusionMode back to Normal as a
side effect, so a window declaring 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.

Each overlay now declares ExclusionMode.Ignore alone. The capture picker
was the visible failure: it started under the bar, the full-output freeze
frame was squeezed into a shorter box, and every selection landed one
bar-height off in the real capture.

tests/quickshell/exclusion-idiom-contract fails any file that pairs them.
2026-09-14 14:36:24 -04:00
Gabriel Brown c1bbc69c8a Fix: Pass an explicit value to eza --icons in the ls and lt aliases
eza 0.23.5 gives --icons an optional WHEN value, so a trailing --icons
swallowed the path argument and `ls docs` failed with "invalid value
'docs' for '--icons'". Using --icons=auto makes both aliases independent
of flag order.

Claude-Session: https://claude.ai/code/session_017trjCxkXTdv7Z5ePfGWYtW
2026-09-07 17:16:17 -04:00
Gabriel Brown fa8b14e05e Fix: Adopt a Terra the machine already trusts
The repository audit made any Terra that is not Panama's own pinned form a
trust-root failure, and status 78 then stopped every stage before it ran. A
machine that installed Terra the way Terra documents it -- terra-release's own
repo file, a metalink, the key at its stock path -- was classified hostile and
had no way back, because install_terra_repository refused to touch a machine
terra-release had already reached. A gate with no door.

The trust root is the signing key, and that key is byte-for-byte the
fingerprint this repository reviewed and pinned, with every signature check
already on. So verify the fingerprint and adopt the configuration into the
pinned form instead of refusing it. Adoption needs no network and no DNF, it
runs before any other transaction in the stage, and it is repeatable, which it
has to be: terra-release owns that file and restores it on update.

Adoption stays narrow. The pinned fingerprint must match both the reviewed key
and the key the machine actually verifies against, the gpgkey must be a local
file under the system trust directory, and the endpoint must be one Terra
itself serves -- so the reviewed baseurl or the reviewed metalink host, now
pinned as TERRA_METALINK_BASEURL. An unknown key, a redirected baseurl, a
second enabled Terra, or a disabled signature check is still a hard refusal.

A refusal also stops less than it did. It suppresses the stages that open DNF
and the migrations, which may run a transaction of their own, and the run still
exits 78. It no longer stops link-dotfiles, link-skills or link-user, which
read no repository and install no package. Exiting before them is what left
this laptop with a stale ~/.claude/skills and no shipped skill reachable.

Also stub ensure_flathub_remote in the extras contract, which has been failing
since that call was added to install_extra_category without one.

Claude-Session: https://claude.ai/code/session_01PeTrG9dGY89UWuhGm4Pr1s
2026-08-28 14:53:48 -04:00
Gabriel Brown b5832fc94a Fix installer state and launcher freshness checks 2026-08-27 17:31:46 -04:00
Gabriel Brown 99156442b5 Merge branch 'main' into codex/repo-audit-remediation-package-2
# Conflicts:
#	README.md
#	setup/scripts/install-packages
2026-08-27 16:51:53 -04:00
Gabriel Brown 4c27203214 Route pre-MR review through the work account 2026-08-27 16:39:16 -04:00
Gabriel Brown c71d6c8799 Publish skills through the agent-neutral skill home 2026-08-27 16:39:16 -04:00
Gabriel Brown cb305f6662 Install ChatGPT Desktop from a repository this checkout can verify
OpenAI ships an official Linux RPM now, so the community wrapper goes away:
`panama app chatgpt-desktop` built codex-desktop from the upstream macOS disk
image and ran a local rebuild daemon to keep it current, and the official
package comes from a repository that upgrades with everything else. The app
file, the help example and the dock's pinned id all move over, and a migration
replaces the build on machines that already have it -- official package on
before the community one comes off, so a failure part-way still leaves an app.

The install itself does not follow upstream's instructions. Those are "download
this RPM and install it", and the RPM's own root scriptlet is what writes the
repository file and drops the signing key into /etc/pki/rpm-gpg -- so root runs
an unverified download and then learns from it what to trust. That is the shape
the repository audit forbids: no network response is executed as root without a
verified digest or signature first.

OpenAI publishes no key and no fingerprint anywhere an install could fetch and
check them, so the key is pinned here instead. setup/keys/ carries it and says
where it came from, including the honest part -- this is trust established on
first use and then held, not trust verified against the publisher. setup/lib/
chatgpt-package verifies that copy's fingerprint, installs it, and writes the
repository with gpgcheck and repo_gpgcheck on before anything is installed, so
dnf checks the metadata signature and the package signature itself. It is byte
for byte the repository the scriptlet would have written, so nothing churns
afterwards, and every later upgrade goes through the same key. Both callers use
it; a verification failure skips ChatGPT rather than installing it anyway.

The contract proves the pinned key is the key the library names, that a
missing, unreadable or mismatched key writes nothing at all, that what is
written actually turns the checks on, and that neither caller hands root a
downloaded RPM.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:48:20 -04:00
Gabriel Brown 1ee42f2cb6 Fix: Sort the contract manifest in byte order, not the machine's
The manifest is written in byte order, but both the runner and the manifest
contract discovered contracts with a bare `sort` and compared them with bash's
`<` -- and both of those follow LC_COLLATE. Under en_US.UTF-8 the collation
folds punctuation away, so `calendar_agenda_bridge_test.py` sorts before
`calendar-agenda-helper-contract` instead of after it, and eight pairs that
differ only by `-` against `_` come back out of order.

The effect was that `tests/setup/contract-manifest-contract` failed on this
machine, and `panama test` refused to run at all, with eight identical "paths
are not lexicographically sorted" findings and nothing naming which paths. A
gate whose answer depends on the machine's LANG is not a gate, so the sort and
the comparison are both pinned to byte order. LC_ALL rather than LC_COLLATE,
because an exported LC_ALL outranks it and would have put the bug back.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:47:47 -04:00
Gabriel Brown 1ae3825fda Alias a second Claude Code configuration
CLAUDE_CONFIG_DIR points the CLI at a different config home, so `klaude`
runs Claude Code against ~/.klaude -- its own settings, auth and history --
without disturbing the default one this machine already uses.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:39:43 -04:00
50 changed files with 1445 additions and 228 deletions
@@ -9,7 +9,7 @@ You are editing a running desktop, not a codebase that gets deployed later. Ever
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
drives every rule below. drives every rule below.
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 - `README.md` — layout, the `panama` command, how installing and updating work
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one - `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
@@ -62,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. `PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
Successful stdout stays quiet. Successful stderr is surfaced as a warning. Successful stdout stays quiet. Successful stderr is surfaced as a warning.
- Contracts run directly too: `tests/setup/interview-contract`. - Contracts run directly too: `tests/setup/interview-contract`.
- After changing `PreferenceSchema.qml` or `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-docs` (writes `docs/settings.md`) and
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links). `config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale. Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/panama
+4
View File
@@ -12,6 +12,10 @@
/config/dot/espanso/match/identity.yml /config/dot/espanso/match/identity.yml
# Ignore backups of old config files # Ignore backups of old config files
/config/old /config/old
# MCP bearer tokens. The server list in user/agents/mcp/servers is tracked
# and names which variable carries each token; the tokens themselves are
# machine-local, because this repo is public.
/user/agents/mcp/env
# Ignore Wireguard config of course! # Ignore Wireguard config of course!
/config/wg/** /config/wg/**
# Ignore Neovim lazy-lock file # Ignore Neovim lazy-lock file
+7 -4
View File
@@ -102,7 +102,7 @@ in order, without stopping again:
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path | | `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen | | `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
| `link-dotfiles` | Symlinks `config/dot/<name>``~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome | | `link-dotfiles` | Symlinks `config/dot/<name>``~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
| `link-skills` | Links the agent skills in `skills/` into `~/.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) | | `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
| `link-vicinae-scripts` | Publishes the Vicinae script commands | | `link-vicinae-scripts` | Publishes the Vicinae script commands |
@@ -235,10 +235,13 @@ server/ The server role: compose services (one directory per
service), the nightly image updater, and its units. See service), the nightly image updater, and its units. See
server/README.md server/README.md
skills/ Agent skills for operating this desktop, linked into skills/ Agent skills for operating this desktop, linked into
~/.claude/skills ~/.agents/skills and ~/.claude/skills
setup/ setup/
apps/ Applications built from source, one file each 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 packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install scripts/ Run in order by ./install
tests/ Contracts. See below tests/ Contracts. See below
@@ -247,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## 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 contract by the capabilities it needs. Run the hermetic set, or grant a
specific external capability when automation needs it: specific external capability when automation needs it:
+8 -2
View File
@@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET}
$PROGRAM upgrade $PROGRAM upgrade
$PROGRAM apps $PROGRAM apps
$PROGRAM app $PROGRAM app
$PROGRAM app chatgpt-desktop
EOF EOF
} }
@@ -451,11 +450,16 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
contract_paths() { contract_paths() {
local candidate local candidate
# The manifest is kept in byte order, so both the discovery sort and the
# comparison below have to be byte order too. A UTF-8 collation folds the
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
# a gate that passes or fails on the machine's LANG is not a gate.
while IFS= read -r candidate; do while IFS= read -r candidate; do
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue [[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}" printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
done < <(find "$PANAMA_DIR/tests" -type f \ done < <(find "$PANAMA_DIR/tests" -type f \
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort) -not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
} }
contract_manifest_entries() { contract_manifest_entries() {
@@ -478,6 +482,8 @@ validate_contract_manifest() {
require_contract_manifest || return 1 require_contract_manifest || return 1
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST" local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
# Byte order, for the same reason contract_paths sorts in it.
local LC_ALL=C
local line capabilities path extra previous_comment="" previous_was_comment=0 local line capabilities path extra previous_comment="" previous_was_comment=0
local previous_path="" capability discovered local previous_path="" capability discovered
local -a capability_list=() findings=() local -a capability_list=() findings=()
+3 -2
View File
@@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
alias nlc='nvim leetcode.nvim' alias nlc='nvim leetcode.nvim'
alias clauded='claude --allow-dangerously-skip-permissions' alias clauded='claude --allow-dangerously-skip-permissions'
alias claudedr='claude --allow-dangerously-skip-permissions --resume' alias claudedr='claude --allow-dangerously-skip-permissions --resume'
alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude'
alias commit-repo='git add -A && git commit -m "Update stuff" && git push' alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
# Docker Shortcuts # Docker Shortcuts
@@ -36,9 +37,9 @@ alias fprint-on='sudo authselect enable-feature with-fingerprint'
alias fprint-off='sudo authselect disable-feature with-fingerprint' alias fprint-off='sudo authselect disable-feature with-fingerprint'
# File system # File system
alias ls='eza -lh --group-directories-first --icons' alias ls='eza -lh --group-directories-first --icons=auto'
alias lsa='ls -a' 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' alias lta='lt -a'
# Fedora's bat installs /usr/bin/bat; batcat is the Debian name. # Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
alias ff="fzf --preview 'bat --style=numbers --color=always {}'" alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
+14 -4
View File
@@ -244,10 +244,20 @@ local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5 local shipped_scale = 1.5
local shipped_transform = 0 local shipped_transform = 0
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4 -- 8-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 -- HBR3 and reliant on DSC, and this panel's link is marginal: every modeset
-- a lower mode, drop this to 8 first. -- retrains it and blanks the screen. 8bpc keeps headroom on the link.
local shipped_bitdepth = 10 --
-- Related: directScanoutPolicy is 0 in Panama settings (2026-09-13). With
-- scanout on, a fullscreen game whose buffer depth differs from the desktop
-- (games ship both 8- and 10-bit swapchains) makes Hyprland change the output
-- format, and on amdgpu a format change is a full modeset. Compositing always
-- keeps the format fixed, so the link never retrains mid-game.
--
-- vrrPolicy is also 0 there. VRR on this panel loses sync and blacks out
-- (seen on GNOME in July 2026 and again here); a 60Hz panel gains little
-- from it anyway.
local shipped_bitdepth = 8
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header. -- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
local shipped_cm = "auto" local shipped_cm = "auto"
+24 -2
View File
@@ -125,6 +125,16 @@ hl.window_rule({
no_dim = true, no_dim = true,
}) })
-- Steam games never get true fullscreen. Everything Hyprland does only for a
-- real fullscreen window (direct scanout, VRR, tearing, auto HDR) makes this
-- panel's marginal DP link retrain and blank (2026-09-13). "1 2" keeps the
-- window maximized internally while the game believes it is fullscreen, which
-- is what borderless windowed looks like from the game's side.
hl.window_rule({
match = { class = "^steam_app_\\d+$" },
fullscreen_state = "1 2",
})
-- Steam itself is a normal window, but its transient popups are a mess. -- Steam itself is a normal window, but its transient popups are a mess.
hl.window_rule({ hl.window_rule({
match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" }, match = { class = "^steam$", title = "^(Friends List|Steam Settings|Special Offer.*)$" },
@@ -269,16 +279,28 @@ hl.layer_rule({
ignore_alpha = 0.2, 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({ hl.layer_rule({
name = "qs-overlay", name = "qs-overlay",
match = { namespace = "^qs-(overview|capture|screen-intelligence)$" }, match = { namespace = "^qs-(overview|screen-intelligence)$" },
blur = true, blur = true,
ignore_alpha = 0.4, ignore_alpha = 0.4,
dim_around = true, dim_around = true,
no_screen_share = true, no_screen_share = true,
}) })
-- The screenshot picker paints an opaque frozen frame edge to edge, so blur and
-- dim would be compositor work on pixels nobody sees. no_anim is load-bearing:
-- services/Capture.qml unmaps the picker and runs grim 90ms later, and the
-- 200ms layersOut fade would otherwise put the half-faded picker, dimming and
-- all, into every screenshot.
hl.layer_rule({
name = "qs-capture",
match = { namespace = "^qs-capture$" },
no_anim = true,
no_screen_share = true,
})
-- Notification toasts. Blurred like every other shell surface -- without this -- Notification toasts. Blurred like every other shell surface -- without this
-- the cards are a near-transparent fill sitting directly on the wallpaper and -- the cards are a near-transparent fill sitting directly on the wallpaper and
-- read as washed out rather than as glass. -- read as washed out rather than as glass.
@@ -1805,7 +1805,7 @@ Singleton {
"org.mozilla.thunderbird_esr", "com.slack.Slack", "org.mozilla.thunderbird_esr", "com.slack.Slack",
"app.bluebubbles.BlueBubbles", "rustdesk", "app.bluebubbles.BlueBubbles", "rustdesk",
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude", "io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
"codex-desktop", "md.obsidian.Obsidian", "chatgpt", "md.obsidian.Obsidian",
"com.obsproject.Studio", "steam" "com.obsproject.Studio", "steam"
] ]
}, },
@@ -27,9 +27,13 @@ PanelWindow {
right: true right: true
} }
// Fullscreen overlays must not reserve space, or every window on the // Ignore, and nothing else: reserve no space (or every window on the
// workspace gets resized as the picker opens and closes. // workspace resizes as the picker opens and closes) and respect nobody's
exclusiveZone: 0 // (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.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@@ -57,7 +57,6 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind // The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind
@@ -55,7 +55,6 @@ PanelWindow {
color: "transparent" color: "transparent"
// A dock that reserved space would not be intellihiding. // A dock that reserved space would not be intellihiding.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename. // Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
@@ -40,7 +40,6 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted // Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
@@ -44,7 +44,6 @@ PanelWindow {
// Reserve nothing and respect nothing: the glow is drawn over the whole // Reserve nothing and respect nothing: the glow is drawn over the whole
// output including under the bar and the dock, which is what makes it // output including under the bar and the dock, which is what makes it
// visible from wherever the eyes happen to be. // visible from wherever the eyes happen to be.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
color: "transparent" color: "transparent"
@@ -29,7 +29,6 @@ PanelWindow {
screen: root.modelData screen: root.modelData
anchors.bottom: true anchors.bottom: true
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3 margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
implicitWidth: root.desiredWidth implicitWidth: root.desiredWidth
implicitHeight: 64 implicitHeight: 64
@@ -22,7 +22,6 @@ PanelWindow {
} }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename. // Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
@@ -34,11 +34,23 @@ Item {
visible: root.rowCount > 0 visible: root.rowCount > 0
implicitHeight: rows.implicitHeight implicitHeight: rows.implicitHeight
// Probing I2C takes on the order of a second, so it waits until the panel // Probing I2C takes on the order of a second, so it waits until the quick
// is actually on screen rather than running at shell startup. Monitors do // settings panel is actually on screen rather than running at shell
// not come and go, so once is enough. // 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() // 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 // `-m` is the machine-readable form: name,class,current,percent,max
Process { Process {
@@ -22,7 +22,6 @@ Variants {
implicitWidth: 260 implicitWidth: 260
implicitHeight: 172 implicitHeight: 172
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
mask: Region {} mask: Region {}
@@ -383,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 0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back. landed is reading the value back.
**`exclusionMode: ExclusionMode.Ignore` goes alone.** Quickshell's `exclusiveZone`
setter flips `exclusionMode` back to `Normal` as a side effect, so a window that
declares both is at the mercy of which property the QML engine applies last.
The 2026-09-14 Qt update changed that order and every full-screen overlay slid
under the bar. `tests/quickshell/exclusion-idiom-contract` fails any file that
pairs them.
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides, **The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
and it ranges from a half-screen split to the full display. `SliderRow` stacks and it ranges from a half-screen split to the full display. `SliderRow` stacks
its control under the label below 520px. Test narrow. its control under the label below 520px. Test narrow.
@@ -78,7 +78,6 @@ PanelWindow {
anchors { top: true; bottom: true; left: true; right: true } anchors { top: true; bottom: true; left: true; right: true }
color: "transparent" color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "qs-popover-welcome" WlrLayershell.namespace: "qs-popover-welcome"
+4 -1
View File
@@ -341,7 +341,10 @@ esac'
property bool record: false property bool record: false
property bool intelligence: false property bool intelligence: false
// A handful of frames at 60Hz, enough for the compositor to recomposite // A handful of frames at 60Hz, enough for the compositor to recomposite
// the output without the overlay on it. // 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 interval: 90
onTriggered: { onTriggered: {
const r = commitDelay.rect; const r = commitDelay.rect;
+75 -22
View File
@@ -71,7 +71,7 @@ source "$PANAMA_PATH/bin/ascii"
# time. On an upgrade it is worth running only when its package lists or # time. On an upgrade it is worth running only when its package lists or
# reviewed installer trust inputs changed, so this hashes them and remembers # reviewed installer trust inputs changed, so this hashes them and remembers
# the result. The framed, sorted stream includes top-level package files, the # the result. The framed, sorted stream includes top-level package files, the
# package-stage adapter, 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. # both relative paths and bytes are part of the state.
# #
# A content hash rather than a git range, because Panama is developed in place: # A content hash rather than a git range, because Panama is developed in place:
@@ -93,7 +93,10 @@ hash_packages() {
for fixed_input in \ for fixed_input in \
"$PANAMA_PATH/setup/scripts/install-packages" \ "$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 [[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1
done done
@@ -102,7 +105,10 @@ hash_packages() {
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1 find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1
printf '%s\0' \ printf '%s\0' \
"$PANAMA_PATH/setup/scripts/install-packages" \ "$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance" || exit 1 "$PANAMA_PATH/setup/lib/artifact-provenance" \
"$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 find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do } | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
relative="${file#"$PANAMA_PATH"/}" relative="${file#"$PANAMA_PATH"/}"
@@ -116,12 +122,11 @@ hash_packages() {
} }
packages_needed() { packages_needed() {
local current_hash recorded_hash local current_hash="$1" recorded_hash
(( FORCE_PACKAGES )) && return 0 (( FORCE_PACKAGES )) && return 0
(( UPGRADE )) || return 0 (( UPGRADE )) || return 0
[[ -r "$PACKAGES_HASH" ]] || return 0 [[ -r "$PACKAGES_HASH" ]] || return 0
current_hash="$(hash_packages)" || return 2
recorded_hash="$(cat "$PACKAGES_HASH")" || return 2 recorded_hash="$(cat "$PACKAGES_HASH")" || return 2
[[ "$current_hash" != "$recorded_hash" ]] [[ "$current_hash" != "$recorded_hash" ]]
} }
@@ -130,10 +135,12 @@ packages_needed() {
# documents for its markers: a step that did not complete has not happened, and # documents for its markers: a step that did not complete has not happened, and
# recording it as done hides it forever. # recording it as done hides it forever.
record_packages_hash() { record_packages_hash() {
local temporary_hash local starting_hash="$1" current_hash temporary_hash
current_hash="$(hash_packages)" || return 1
[[ "$current_hash" == "$starting_hash" ]] || return 1
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1 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" mv -f -- "$temporary_hash" "$PACKAGES_HASH"
else else
rm -f -- "$temporary_hash" rm -f -- "$temporary_hash"
@@ -142,20 +149,37 @@ record_packages_hash() {
} }
# Repository trust is checked before the installer can reach its bootstrap DNF. # Repository trust is checked before the installer can reach its bootstrap DNF.
# Status 78 is reserved for a trust-root failure and is propagated unchanged so # Status 78 is reserved for a trust-root failure. It suppresses every stage that
# no later stage, especially install-hardware, can invoke DNF with that repo. # 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 TERRA_TRUST_FAILURE_STATUS=78
DNF_STAGES=(install-packages change-settings install-hardware)
package_trust_refused=0
stage_opens_dnf() {
local candidate="$1" dnf_stage
for dnf_stage in "${DNF_STAGES[@]}"; do
[[ "$candidate" == "$dnf_stage" ]] && return 0
done
return 1
}
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages" trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
if [[ ! -x "$trust_preflight" ]]; then if [[ ! -x "$trust_preflight" ]]; then
printf 'install: package repository trust preflight is unavailable\n' >&2 printf 'install: package repository trust preflight is unavailable\n' >&2
exit "$TERRA_TRUST_FAILURE_STATUS" package_trust_refused=1
fi elif ! "$trust_preflight" --trust-preflight; then
if "$trust_preflight" --trust-preflight; then
:
else
trust_status=$?
printf 'install: package repository trust preflight failed\n' >&2 printf 'install: package repository trust preflight failed\n' >&2
exit "$trust_status" package_trust_refused=1
fi fi
# ── The interview ──────────────────────────────────────────────────────────── # ── The interview ────────────────────────────────────────────────────────────
@@ -175,7 +199,7 @@ fi
# Gated exactly like the interview itself: under --upgrade no questions are # Gated exactly like the interview itself: under --upgrade no questions are
# asked, so nothing here is used, and a machine that cannot install gum must # asked, so nothing here is used, and a machine that cannot install gum must
# not have that stop an upgrade that never needed it. # not have that stop an upgrade that never needed it.
if (( ! UPGRADE )); then if (( ! UPGRADE && ! package_trust_refused )); then
bootstrap=() bootstrap=()
command -v gum >/dev/null 2>&1 || bootstrap+=(gum) command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
# The probe tools serve only the hardware questions, which a server is never # The probe tools serve only the hardware questions, which a server is never
@@ -319,7 +343,7 @@ fi
if [[ "$PANAMA_ROLE" == server ]]; then if [[ "$PANAMA_ROLE" == server ]]; then
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity) STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
else else
STAGES=(install-packages link-dotfiles link-skills link-user 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 fi
# The two an upgrade drops. Both exist only to act on interview answers, and # The two an upgrade drops. Both exist only to act on interview answers, and
@@ -342,9 +366,16 @@ for stage in "${STAGES[@]}"; do
script="$PANAMA_PATH/setup/scripts/$stage" script="$PANAMA_PATH/setup/scripts/$stage"
[[ -x "$script" ]] || continue [[ -x "$script" ]] || continue
printf '\n=== %s ===\n' "$stage" printf '\n=== %s ===\n' "$stage"
if (( package_trust_refused )) && stage_opens_dnf "$stage"; then
echo "Skipped: the package repository trust check refused package work."
continue
fi
if [[ "$stage" == install-packages ]]; then if [[ "$stage" == install-packages ]]; then
package_state_status=0 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 if (( package_state_status == 1 )); then
echo "The package lists have not changed since the last run; skipping." echo "The package lists have not changed since the last run; skipping."
echo "Run with --packages to install them anyway." echo "Run with --packages to install them anyway."
@@ -357,16 +388,19 @@ for stage in "${STAGES[@]}"; do
fi fi
if "$script"; then if "$script"; then
if [[ "$stage" == install-packages ]]; then if [[ "$stage" == install-packages ]]; then
if ! record_packages_hash; then if ! record_packages_hash "$package_start_hash"; then
failed+=("$stage") failed+=("$stage")
printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2 printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2
fi fi
fi fi
else else
stage_status=$? stage_status=$?
# A configuration change between the preflight and this stage. Suppress the
# remaining DNF stages, keep the safe ones, and carry the status to the end.
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2 printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
exit "$stage_status" package_trust_refused=1
continue
fi fi
failed+=("$stage") failed+=("$stage")
printf '!!! %s failed\n' "$stage" >&2 printf '!!! %s failed\n' "$stage" >&2
@@ -392,8 +426,16 @@ done
# written for -- and baselining would skip every one of them forever. Every # written for -- and baselining would skip every one of them forever. Every
# migration is self-guarding and a no-op where it does not apply, so running # migration is self-guarding and a no-op where it does not apply, so running
# them is the safe direction. # them is the safe direction.
#
# Held back when package work was refused. A migration is free to run a DNF
# transaction -- the ChatGPT package replacement does exactly that -- so the
# repositories have to be trustworthy before any of them is allowed to run.
# They are not marked applied either, so the next run still has them pending.
migrate="$PANAMA_PATH/bin/panama-migrate" migrate="$PANAMA_PATH/bin/panama-migrate"
if [[ -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' printf '\n=== migrations ===\n'
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
"$migrate" run || failed+=(migrations) "$migrate" run || failed+=(migrations)
@@ -441,6 +483,17 @@ else
retry='./install' retry='./install'
fi fi
# Reported last and on its own, because it is not an ordinary stage failure:
# everything safe did run, and what did not run is named rather than buried in a
# list. The exit status stays 78 so a caller can still tell the two apart.
if (( package_trust_refused )); then
printf 'Package work was refused: the Terra repository configuration on this\n' >&2
printf 'machine is not one Panama can verify. Skipped: %s\n' "${DNF_STAGES[*]}" >&2
printf 'Everything that touches no repository was still applied.\n' >&2
printf 'Inspect it with: panama diagnose\n' >&2
exit "$TERRA_TRUST_FAILURE_STATUS"
fi
if (( ${#failed[@]} == 0 )); then if (( ${#failed[@]} == 0 )); then
if (( UPGRADE )); then if (( UPGRADE )); then
echo "Panama is up to date." echo "Panama is up to date."
+64
View File
@@ -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."
-19
View File
@@ -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
}
+38
View File
@@ -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 }'
```
+28
View File
@@ -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-----
+10 -2
View File
@@ -8,6 +8,7 @@ declare -gA INSTALLER_PROVENANCE=()
_primary_key_fingerprints() ( _primary_key_fingerprints() (
local home local home
set -o pipefail
home="$(mktemp -d)" || exit 1 home="$(mktemp -d)" || exit 1
chmod 700 "$home" chmod 700 "$home"
trap 'rm -rf -- "$home"' EXIT trap 'rm -rf -- "$home"' EXIT
@@ -19,14 +20,20 @@ _primary_key_fingerprints() (
key_fingerprint_matches() { key_fingerprint_matches() {
local file="$1" expected="$2" local file="$1" expected="$2"
local output
local -a primary_fingerprints=() local -a primary_fingerprints=()
mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$file") output="$(_primary_key_fingerprints "$file")" || return 1
[[ -n "$output" ]] || return 1
mapfile -t primary_fingerprints <<<"$output"
[[ ${#primary_fingerprints[@]} -eq 1 && "${primary_fingerprints[0]}" == "$expected" ]] [[ ${#primary_fingerprints[@]} -eq 1 && "${primary_fingerprints[0]}" == "$expected" ]]
} }
_key_has_one_primary() { _key_has_one_primary() {
local output
local -a primary_fingerprints=() local -a primary_fingerprints=()
mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$1") output="$(_primary_key_fingerprints "$1")" || return 1
[[ -n "$output" ]] || return 1
mapfile -t primary_fingerprints <<<"$output"
[[ ${#primary_fingerprints[@]} -eq 1 ]] [[ ${#primary_fingerprints[@]} -eq 1 ]]
} }
@@ -105,6 +112,7 @@ load_installer_provenance() {
RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \ RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \
FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \ FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \
RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \ RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \
TERRA_METALINK_BASEURL \
HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \ HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \
CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \ CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \
BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \ BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \
+97
View File
@@ -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
}
+1
View File
@@ -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_URL=https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm
RPMFUSION_NONFREE_RELEASE_MAX_BYTES=4194304 RPMFUSION_NONFREE_RELEASE_MAX_BYTES=4194304
TERRA_BASEURL=https://repos.fyralabs.com/terra44 TERRA_BASEURL=https://repos.fyralabs.com/terra44
TERRA_METALINK_BASEURL=https://tetsudou.fyralabs.com/metalink
HYPRLAND_COPR_BASEURL=https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/ HYPRLAND_COPR_BASEURL=https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/
FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo
FLATHUB_DESCRIPTOR_MAX_BYTES=1048576 FLATHUB_DESCRIPTOR_MAX_BYTES=1048576
+189 -23
View File
@@ -89,6 +89,11 @@ source "$PANAMA_PATH/setup/lib/extras-catalog"
source "$PANAMA_PATH/setup/lib/machine-role" source "$PANAMA_PATH/setup/lib/machine-role"
ROLE="$(panama_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 # 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 # single rotted name cannot cost the transaction, then report_missing so a
# skipped name is a warning somebody reads. # skipped name is a warning somebody reads.
@@ -720,8 +725,17 @@ _publish_repository_pair() {
return "$status" 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() { _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() { function reset_block() {
delete values delete values
delete seen delete seen
@@ -741,10 +755,22 @@ _effective_terra_key() {
for (key in required) { for (key in required) {
if (seen[key] != 1) bad = 1 if (seen[key] != 1) bad = 1
} }
if (values["baseurl"] != reviewed_baseurl || values["metalink"] != "" \ if (require_pinned != "") {
|| values["mirrorlist"] != "" || values["gpgcheck"] != "1" \ if (values["baseurl"] != reviewed_baseurl || values["metalink"] != "" \
|| values["pkg_gpgcheck"] != "1" || values["repo_gpgcheck"] != "1" \ || values["mirrorlist"] != "" \
|| values["gpgkey"] != "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama") bad = 1 || 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"] trusted_key = values["gpgkey"]
} }
BEGIN { 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 # Status 0 is one trusted effective Terra identity, 1 is no enabled Terra
# identity, and 2 is an unsafe, duplicated, or unreadable effective state. # 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() { _terra_effective_status() {
local dump gpgkey parse_status=0 local_key local dump gpgkey parse_status=0 local_key
dump="$(LC_ALL=C dnf --quiet --no-plugins --dump-repo-config='*')" || return 2 if (( $# > 0 )); then
gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key)" || parse_status=$? 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" (( parse_status == 0 )) || return "$parse_status"
[[ "$gpgkey" == 'file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' ]] || return 2 [[ "$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" local_key="$PANAMA_SYSTEM_ETC/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama"
@@ -819,18 +857,71 @@ _terra_effective_status() {
|| return 2 || 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 TERRA_TRUST_FAILURE_STATUS=78
preflight_terra_trust() { preflight_terra_trust() {
local status=0 local status=0 adoptable_status=0 dump
_require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' \ _require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' \
|| return "$TERRA_TRUST_FAILURE_STATUS" || return "$TERRA_TRUST_FAILURE_STATUS"
_require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \ _require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \
|| return "$TERRA_TRUST_FAILURE_STATUS" || 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 if (( status == 0 || status == 1 )); then
return 0 return 0
fi 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" log "Effective Terra repository configuration is not trusted; refusing all package work"
return "$TERRA_TRUST_FAILURE_STATUS" return "$TERRA_TRUST_FAILURE_STATUS"
} }
@@ -902,18 +993,67 @@ install_rpmfusion_repositories() {
return "$status" 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() { 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_reviewed_fedora_release || return 1
_require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' || return 1 _require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' || return 1
_require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F || 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 if (( effective_status == 0 )); then
log "Terra repository already configured and verified" log "Terra repository already configured and verified"
return 0 return 0
elif (( effective_status != 1 )); then elif (( effective_status != 1 )); then
log "Effective Terra repository configuration is not trusted" _terra_adoptable_status "$dump" || adoptable_status=$?
return "$TERRA_TRUST_FAILURE_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 fi
if rpm -q terra-release >/dev/null 2>&1; then if rpm -q terra-release >/dev/null 2>&1; then
log "terra-release is installed without one trusted enabled Terra repository" log "terra-release is installed without one trusted enabled Terra repository"
@@ -1172,16 +1312,9 @@ if [[ "$ROLE" == server ]]; then
fi fi
echo -e "\n--- Installing Repositories ---" echo -e "\n--- Installing Repositories ---"
log "Installing RPM Fusion Free and Nonfree Repositories" # Terra goes first so a machine whose Terra is enabled but not yet in Panama's
install_rpmfusion_repositories > /dev/null # reviewed form is adopted before any other transaction below runs against it.
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 bootstraps itself: --repofrompath defines a throwaway repo just long # Terra bootstraps itself: --repofrompath defines a throwaway repo just long
# enough to install terra-release, which then writes the real /etc/yum.repos.d # 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 # 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" log "Installing Terra Repository"
install_terra_repository > /dev/null 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 ---" echo -e "\n--- Installing relevant packages ---"
log "Updating all packages. This may take a while" log "Updating all packages. This may take a while"
sudo dnf update -y --refresh > /dev/null sudo dnf update -y --refresh > /dev/null
@@ -1299,6 +1443,28 @@ if ! install_claude_desktop_if_trusted; then
softly_failed+=("Claude Desktop") softly_failed+=("Claude Desktop")
fi 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 # The RPM ships rustdesk.service already enabled, which is what provides
# unattended access; Panama deliberately does not start it a second time. # unattended access; Panama deliberately does not start it a second time.
install_rustdesk || true install_rustdesk || true
+99
View File
@@ -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
View File
@@ -11,12 +11,11 @@
# this repository, not anybody's personal content, so a stranger who clones # this repository, not anybody's personal content, so a stranger who clones
# Panama wants it for exactly the same reason its author does. # Panama wants it for exactly the same reason its author does.
# #
# ~/.claude/skills was a single symlink into user/agents/skills until now, and # ~/.agents/skills and ~/.claude/skills may each start as a single symlink into
# a directory cannot be two things at once. So the destination becomes a real # user/agents/skills, but a directory cannot point at personal and shipped
# directory and every skill -- shipped here, personal from user/ -- is linked # skills at once. Both destinations become real directories with one link per
# into it one at a time. link-user runs after this stage on purpose: it links # skill. link-user runs after this stage on purpose, so a personal skill named
# last, so a personal skill named like a shipped one wins, which is the # like a shipped one wins in every agent runtime.
# precedence Claude Code itself uses.
set -euo pipefail set -euo pipefail
@@ -25,7 +24,7 @@ log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
SKILLS_DIR="$PANAMA_PATH/skills" SKILLS_DIR="$PANAMA_PATH/skills"
PANAMA_OLD="$PANAMA_PATH/config/old" PANAMA_OLD="$PANAMA_PATH/config/old"
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; } [[ -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" log "Moved existing $destination to $backup"
} }
# The destination itself has to be a real directory before anything can be # Each destination has to be a real directory before anything can be linked
# linked into it. An old whole-directory symlink is removed; a regular file # into it. An old whole-directory symlink is removed; a regular file somebody
# somebody left at this path is kept, in config/old/. # left at the path is kept in config/old/.
mkdir -p "$(dirname "$DESTINATION")" for destination in "${DESTINATIONS[@]}"; do
if [[ -L "$DESTINATION" ]]; then mkdir -p "$(dirname "$destination")"
rm -f "$DESTINATION" if [[ -L "$destination" ]]; then
log "Removed the old $DESTINATION symlink; skills are linked one by one now" rm -f "$destination"
elif [[ -e "$DESTINATION" && ! -d "$DESTINATION" ]]; then log "Removed the old $destination symlink; skills are linked one by one now"
displace "$DESTINATION" elif [[ -e "$destination" && ! -d "$destination" ]]; then
fi displace "$destination"
mkdir -p "$DESTINATION" fi
mkdir -p "$destination"
done
linked=0 linked=0
for skill in "$SKILLS_DIR"/*; do for skill in "$SKILLS_DIR"/*; do
[[ -e "$skill" ]] || continue [[ -e "$skill" ]] || continue
name="$(basename "$skill")" name="$(basename "$skill")"
target="$DESTINATION/$name" for destination in "${DESTINATIONS[@]}"; do
target="$destination/$name"
displace "$target" displace "$target"
ln -s "$skill" "$target" ln -s "$skill" "$target"
log "Linked skills/$name → $target" log "Linked skills/$name → $target"
done
linked=$(( linked + 1 )) linked=$(( linked + 1 ))
done done
+9 -3
View File
@@ -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 # alone takes long enough to be worth not repeating on every re-run of
# a stage that is otherwise nearly instant. # a stage that is otherwise nearly instant.
built="$vicinae_data_dir/extensions/$name" built="$vicinae_data_dir/extensions/$name"
if [[ -d "$built" && "$extension/src" -ot "$built" ]]; then if [[ -d "$built" ]]; then
printf 'Vicinae extension %s is already built\n' "$name" newer_source=''
continue 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 fi
printf 'Building Vicinae extension %s\n' "$name" printf 'Building Vicinae extension %s\n' "$name"
+2
View File
@@ -82,6 +82,7 @@ live-desktop tests/quickshell/dock-position-contract
# Reads Hyprland option descriptions from the live compositor to verify enum # Reads Hyprland option descriptions from the live compositor to verify enum
# mappings. # mappings.
live-compositor tests/quickshell/enum-hypr-map-contract live-compositor tests/quickshell/enum-hypr-map-contract
hermetic tests/quickshell/exclusion-idiom-contract
hermetic tests/quickshell/fingerprint-contract hermetic tests/quickshell/fingerprint-contract
# Reads the host firewall state through the production firewall helper. # Reads the host firewall state through the production firewall helper.
live-host tests/quickshell/firewall-contract live-host tests/quickshell/firewall-contract
@@ -247,6 +248,7 @@ hermetic tests/server/containers-shape-contract
hermetic tests/server/panama-server-contract hermetic tests/server/panama-server-contract
hermetic tests/setup/apps-contract hermetic tests/setup/apps-contract
hermetic tests/setup/boot-contract hermetic tests/setup/boot-contract
hermetic tests/setup/chatgpt-package-contract
hermetic tests/setup/contract-manifest-contract hermetic tests/setup/contract-manifest-contract
hermetic tests/setup/crash-watch-contract hermetic tests/setup/crash-watch-contract
hermetic tests/setup/desktop-first-contract hermetic tests/setup/desktop-first-contract
+45
View File
@@ -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
-2
View File
@@ -32,8 +32,6 @@ panama="$repo_dir/bin/panama"
findings=() findings=()
note() { findings+=("$1"); } note() { findings+=("$1"); }
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
shopt -s nullglob shopt -s nullglob
definitions=("$apps_dir"/*) definitions=("$apps_dir"/*)
+195
View File
@@ -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'
+7 -1
View File
@@ -11,16 +11,22 @@ manifest="$repo_dir/tests/contracts.manifest"
discover_contracts() { discover_contracts() {
discovered_contracts=() discovered_contracts=()
# Byte order, exactly as the runner discovers them. A UTF-8 collation folds
# the punctuation away and reorders the pairs that differ only by `-` and
# `_`, so a manifest correct here would be wrong on a machine with a
# different LANG.
while IFS= read -r path; do while IFS= read -r path; do
[[ -x "$path" || "$path" == *_test.py ]] || continue [[ -x "$path" || "$path" == *_test.py ]] || continue
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}") discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
done < <(find "$repo_dir/tests" -type f \ done < <(find "$repo_dir/tests" -type f \
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort) -not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
} }
validate_manifest() { validate_manifest() {
local candidate="$1" local candidate="$1"
local -n expected_contracts="$2" local -n expected_contracts="$2"
# Byte order, for the same reason discover_contracts sorts in it.
local LC_ALL=C
local line capabilities path extra previous_comment="" previous_was_comment=0 local line capabilities path extra previous_comment="" previous_was_comment=0
local -a capability_list=() local -a capability_list=()
local -A manifest_paths=() capability_counts=() local -A manifest_paths=() capability_counts=()
+9
View File
@@ -65,6 +65,12 @@ trap 'rm -rf "$work"' EXIT
filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")" filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")"
loop="$(sed -n '/^install_extra_category()/,/^}/p' "$installer")" loop="$(sed -n '/^install_extra_category()/,/^}/p' "$installer")"
# install_extra_category verifies the Flathub remote before installing a
# flatpak, and records a soft failure when it cannot. Both live outside the
# extracted function and have contracts of their own, so they stand in here as
# trusted -- what is under test is which targets reach which installer.
deps='ensure_flathub_remote() { :; }
softly_failed=()'
[[ -n "$filter" && -n "$loop" ]] || { [[ -n "$filter" && -n "$loop" ]] || {
printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2 printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2
exit 1 exit 1
@@ -99,6 +105,7 @@ LIST
source "$catalog" source "$catalog"
eval "$filter" eval "$filter"
eval "$loop" eval "$loop"
eval "$deps"
install_extra_category "$fixture" install_extra_category "$fixture"
) )
@@ -132,6 +139,7 @@ printf 'flatpak:org.example.OnlyFlatpak\n' >"$flatpak_only"
source "$catalog" source "$catalog"
eval "$filter" eval "$filter"
eval "$loop" eval "$loop"
eval "$deps"
install_extra_category "$flatpak_only" install_extra_category "$flatpak_only"
) )
flatpak_only_status=$? flatpak_only_status=$?
@@ -149,6 +157,7 @@ grep -q 'flatpak install -y flathub org.example.OnlyFlatpak' <<<"$(cat "$calls"
source "$catalog" source "$catalog"
eval "$filter" eval "$filter"
eval "$loop" eval "$loop"
eval "$deps"
EXTRAS_DIR="$extras_dir" EXTRAS_DIR="$extras_dir"
for extra in ${PANAMA_EXTRAS:-}; do for extra in ${PANAMA_EXTRAS:-}; do
[[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra" [[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra"
+62 -1
View File
@@ -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' || note 'the extension build never sources nvm, so npm is missing on any machine without a system node'
# node_modules is a dependency tree, not configuration. # node_modules is a dependency tree, not configuration.
git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \ git -C "$repo_dir" check-ignore --no-index -q \
"$extension/node_modules/package.json" 2>/dev/null \
|| note 'the extension node_modules is not gitignored' || note 'the extension node_modules is not gitignored'
# npm must honour the committed dependency graph. This disposable fixture # npm must honour the committed dependency graph. This disposable fixture
@@ -167,6 +168,66 @@ stage_output="$(PATH="$fixture_root/bin:$PATH" PANAMA_PATH="$fixture_root" \
cmp -s -- "$lock_before" "$lockfile" \ cmp -s -- "$lock_before" "$lockfile" \
|| note 'a rejected Vicinae lockfile mismatch changed package-lock.json' || note 'a rejected Vicinae lockfile mismatch changed package-lock.json'
# Editing an existing source file does not change its parent directory's
# 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 ─────────────────────────────────────────────────────────────────── # ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then if (( ${#findings[@]} > 0 )); then
+88 -2
View File
@@ -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" 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" 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 \ expect_success verify_detached_signature \
"$fixtures/fixture-key.asc" "$fixtures/SHASUMS256.txt.asc" "$fixtures/SHASUMS256.txt" "$fixtures/fixture-key.asc" "$fixtures/SHASUMS256.txt.asc" "$fixtures/SHASUMS256.txt"
expect_failure verify_detached_signature \ expect_failure verify_detached_signature \
@@ -336,8 +361,9 @@ expect_failure load_installer_provenance "$parser_fixture"
installer_fixture="$test_tmp/installer-fixture" installer_fixture="$test_tmp/installer-fixture"
mkdir -p "$installer_fixture/setup/lib" "$installer_fixture/setup/provenance/keys" \ mkdir -p "$installer_fixture/setup/lib" "$installer_fixture/setup/provenance/keys" \
"$installer_fixture/setup/scripts" "$installer_fixture/setup/scripts"
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/extras-catalog" \ cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/chatgpt-package" \
"$repo_dir/setup/lib/machine-role" "$installer_fixture/setup/lib/" "$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 "$config" "$installer_fixture/setup/provenance/installers.conf"
cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/" cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/"
sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \ sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \
@@ -868,7 +894,16 @@ if [[ -n "$query" ]]; then
trusted|wrong-key) mode=trusted ;; trusted|wrong-key) mode=trusted ;;
nogpg) mode=legacy ;; nogpg) mode=legacy ;;
wrong-url) mode=override-url ;; wrong-url) mode=override-url ;;
stock|stock-wrong-key) mode=stock ;;
esac 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" ]] \ 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 && grep -q '^baseurl=https://repos.fyralabs.com/terra44$' "$STUB_ETC/yum.repos.d/terra.repo"; then
mode=trusted mode=trusted
@@ -900,6 +935,13 @@ if [[ -n "$query" ]]; then
printf 'metalink = https://tetsudou.fyralabs.com/terra44\nmirrorlist = \n' printf 'metalink = https://tetsudou.fyralabs.com/terra44\nmirrorlist = \n'
printf 'pkg_gpgcheck = 0\nrepo_gpgcheck = 0\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) override-url)
printf '======== "terra" repository configuration: ========\n' printf '======== "terra" repository configuration: ========\n'
printf 'baseurl = https://evil.invalid/terra44\nenabled = 1\ngpgcheck = 1\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' \ 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" > "$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) wrong-key)
cp "$installer_fixture/setup/provenance/keys/flathub.asc" \ cp "$installer_fixture/setup/provenance/keys/flathub.asc" \
"$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" "$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" || fail "untrusted existing Terra $mode state reached a mutation"
done 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 # An optional security field may be absent, but duplicates are malformed even
# when one copy looks safe. These cases catch the absent/duplicate conflation. # when one copy looks safe. These cases catch the absent/duplicate conflation.
for duplicate_case in \ for duplicate_case in \
+41 -20
View File
@@ -2,7 +2,7 @@
# The manual this machine hands an agent. # 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 # on a Panama desktop will otherwise infer it from the source and get half of it
# wrong. That only helps if what the skills say is true -- and a skill is worse # wrong. That only helps if what the skills say is true -- and a skill is worse
# than no skill when it is stale, because an agent believes it verbatim and does # than no skill when it is stale, because an agent believes it verbatim and does
@@ -16,7 +16,7 @@
# That is a convention on the prose -- name things exactly, in backticks -- # That is a convention on the prose -- name things exactly, in backticks --
# and it is how they should be written anyway. # and it is how they should be written anyway.
# 3. The delivery works: link-skills is a stage, in the right place, and the # 3. The delivery works: link-skills is a stage, in the right place, and the
# personal manifest hands ~/.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 # Sections 1 and 2 report clearly and keep going when a skill is not written
# yet, so this contract is useful while the skills are still being authored. # yet, so this contract is useful while the skills are still being authored.
@@ -32,9 +32,21 @@ manifest="$repo_dir/user/manifest"
findings=() findings=()
note() { findings+=("$1"); } note() { findings+=("$1"); }
# The three, and where each is delivered from. The two under skills/ are shipped # Every directory under skills/ ships to every machine. The project-level
# to every machine; the third is project-level and needs no delivery at all. # panama skill stays in this repository and needs no home-directory delivery.
SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama) 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 ───────────────────────────────────────────────────── # ── 1. Each skill loads ─────────────────────────────────────────────────────
@@ -54,7 +66,7 @@ for relative in "${SKILL_DIRS[@]}"; do
present+=("$directory") present+=("$directory")
# Frontmatter is the first --- delimited block, and a skill without one is # Frontmatter is the first --- delimited block, and a skill without one is
# not a skill: 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")" frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
if [[ -z "$frontmatter" ]]; then if [[ -z "$frontmatter" ]]; then
note "$relative/SKILL.md does not open with a --- frontmatter block" note "$relative/SKILL.md does not open with a --- frontmatter block"
@@ -207,12 +219,12 @@ else
work="$(mktemp -d)" work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
# A checkout and a home of its own. Never the real ones: ~/.claude/skills on # A checkout and a home of its own. Never the real ones: these are somebody's
# this machine is somebody's live agent setup, and a contract that broke it # live agent setup, and a contract that broke them mid-session would be worse
# mid-session would be worse than the bug it was looking for. # than the bug it was looking for.
checkout="$work/Panama" checkout="$work/Panama"
home="$work/home" home="$work/home"
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents"
cp "$linker" "$checkout/setup/scripts/link-skills" cp "$linker" "$checkout/setup/scripts/link-skills"
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md" printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
@@ -220,6 +232,7 @@ else
# symlink, which is what link-user used to leave here. # symlink, which is what link-user used to leave here.
mkdir -p "$work/personal" mkdir -p "$work/personal"
ln -s "$work/personal" "$home/.claude/skills" ln -s "$work/personal" "$home/.claude/skills"
ln -s "$work/personal" "$home/.agents/skills"
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; } run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
@@ -231,22 +244,30 @@ else
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it' || note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
[[ -L "$home/.claude/skills/shipped" ]] \ [[ -L "$home/.claude/skills/shipped" ]] \
|| note 'link-skills did not link each shipped skill as a child of ~/.claude/skills' || note 'link-skills did not link each shipped skill as a child of ~/.claude/skills'
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|| note 'link-skills left ~/.agents/skills a symlink, so shipped and personal skills cannot coexist'
[[ -L "$home/.agents/skills/shipped" ]] \
|| note 'link-skills did not link each shipped skill as a child of ~/.agents/skills'
grep -q 'Agent skills: 1 linked' "$work/log" \ grep -q 'Agent skills: 1 linked' "$work/log" \
|| note 'link-skills does not report how many skills it linked' || note 'link-skills does not report how many skills it linked'
# A real directory at a shipped skill's name is somebody's work: it moves to # A real directory at a shipped skill's name is somebody's work: it moves to
# config/old rather than being deleted, the same promise the other stages # config/old rather than being deleted, the same promise the other stages
# make. A symlink is not, and must not accumulate there. # make. A symlink is not, and must not accumulate there.
rm "$home/.claude/skills/shipped" for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
mkdir -p "$home/.claude/skills/shipped" rm -f "$skill_home/shipped"
printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md" mkdir -p "$skill_home/shipped"
mkdir -p "$home/.claude/skills/untouched" printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md"
mkdir -p "$skill_home/untouched"
done
run run
grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \ [[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \
|| note 'link-skills destroyed a real skill instead of moving it to config/old' || note 'link-skills did not preserve real skills from both agent homes'
[[ -d "$home/.claude/skills/untouched" ]] \ for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|| note 'link-skills removed a skill it does not ship' [[ -d "$skill_home/untouched" ]] \
|| note "link-skills removed an unshipped skill from $skill_home"
done
before="$(find "$checkout/config/old" | wc -l)" before="$(find "$checkout/config/old" | wc -l)"
run run
@@ -287,8 +308,8 @@ fi
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \ grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \
|| note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory' || note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory'
grep -qE '^\s*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \ grep -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|| note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path' || note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills'
grep -q 'linkdir)' "$user_linker" \ grep -q 'linkdir)' "$user_linker" \
|| note 'link-user does not implement the linkdir kind the manifest asks for' || note 'link-user does not implement the linkdir kind the manifest asks for'
grep -q 'linkdir' "$repo_dir/user/README.md" \ grep -q 'linkdir' "$repo_dir/user/README.md" \
+67 -15
View File
@@ -39,6 +39,12 @@ trap 'rm -rf "$tmp"' EXIT
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
link-vicinae-scripts setup-server link-server setup-identity link-vicinae-scripts setup-server link-server setup-identity
install-hardware) install-hardware)
PACKAGE_BEHAVIOR_INPUTS=(
setup/lib/artifact-provenance
setup/lib/chatgpt-package
setup/lib/extras-catalog
setup/lib/machine-role
)
copy_hash_inputs() { copy_hash_inputs() {
local root="$1" source relative local root="$1" source relative
@@ -50,8 +56,10 @@ copy_hash_inputs() {
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0 find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
find "$repo_dir/setup/provenance" -type f -print0 find "$repo_dir/setup/provenance" -type f -print0
) )
mkdir -p "$root/setup/lib" for relative in "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
cp -- "$repo_dir/setup/lib/artifact-provenance" "$root/setup/lib/artifact-provenance" 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 # 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 # Dynamically discovering them makes this fail when a new reviewed input is
# added but omitted from hash_packages. # added but omitted from hash_packages.
for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \ for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \
'setup/scripts/install-packages' 'setup/lib/artifact-provenance'; do 'setup/scripts/install-packages' "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative" printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
install_status=0 install_status=0
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
@@ -282,7 +290,7 @@ done
# Fixed hash inputs must not silently disappear or degrade into a directory or # Fixed hash inputs must not silently disappear or degrade into a directory or
# link. An unreadable package input also proves a failed content read cannot be # link. An unreadable package input also proves a failed content read cannot be
# hidden by the final digest command. # hidden by the final digest command.
for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance; do for fixed_input in setup/scripts/install-packages "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
for case_name in missing directory symlink unreadable; do for case_name in missing directory symlink unreadable; do
case_root="$tmp/hash-${fixed_input//\//-}-$case_name" case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
build_fixture "$case_root" build_fixture "$case_root"
@@ -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" \ cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \
|| note 'a failed package-state hash wrote a new packages-hash stamp' || note 'a failed package-state hash wrote a new packages-hash stamp'
# Inputs changed while install-packages was running were not the inputs it
# 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. # A failing stage must not record the hash, or the failure is hidden forever.
build_fixture "$tmp/c" 1 build_fixture "$tmp/c" 1
install_status=0 install_status=0
@@ -332,30 +360,48 @@ fi
grep -qx 'link-dotfiles' "$tmp/c/ran" \ grep -qx 'link-dotfiles' "$tmp/c/ran" \
|| note 'an ordinary package-stage failure no longer allows later safe stages' || note 'an ordinary package-stage failure no longer allows later safe stages'
# An invalid enabled Terra root is not an ordinary package failure. It must # An untrusted Terra root is not an ordinary package failure, and it is not a
# stop before the installer's bootstrap DNF and before every stage. # 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 build_fixture "$tmp/terra-preflight-hard" 0 78
install_status=0 install_status=0
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$? run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
[[ "$install_status" -eq 78 ]] \ [[ "$install_status" -eq 78 ]] \
|| note "initial Terra trust failure returned $install_status instead of 78" || note "initial Terra trust failure returned $install_status instead of 78"
asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")" grep -qx 'trust-preflight' "$tmp/terra-preflight-hard/ran" \
[[ "$asserted_preflight" == trust-preflight ]] \ || note 'initial Terra trust fixture never reached the preflight'
|| note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}" assert_trust_refusal "$tmp/terra-preflight-hard" 'initial Terra trust failure'
# The trust verifier is itself mandatory. Losing its executable adapter must # The trust verifier is itself mandatory. Losing its executable adapter refuses
# fail closed before interview, bootstrap, or stage work. # package work exactly as a failing verdict does, rather than being ignored.
build_fixture "$tmp/terra-preflight-missing" build_fixture "$tmp/terra-preflight-missing"
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages" rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
install_status=0 install_status=0
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$? run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
[[ "$install_status" -eq 78 ]] \ [[ "$install_status" -eq 78 ]] \
|| note "missing Terra trust verifier returned $install_status instead of 78" || note "missing Terra trust verifier returned $install_status instead of 78"
[[ ! -s "$tmp/terra-preflight-missing/ran" ]] \ assert_trust_refusal "$tmp/terra-preflight-missing" 'missing Terra trust verifier'
|| note 'missing Terra trust verifier allowed later work'
# The package stage repeats the preflight to close a configuration-change race. # The package stage repeats the preflight to close a configuration-change race.
# Its hard status 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 build_fixture "$tmp/terra-stage-hard" 78 0
install_status=0 install_status=0
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$? run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
@@ -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" || note "stage-time Terra trust failure returned $install_status instead of 78"
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \ grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|| note 'stage-time Terra trust fixture never reached install-packages' || note 'stage-time Terra trust fixture never reached install-packages'
for suppressed in 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" \ grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
&& note "stage-time Terra trust failure still ran $suppressed" && note "stage-time Terra trust failure still ran $suppressed"
done done
grep -q '^migrate ' "$tmp/terra-stage-hard/ran" \
&& note 'stage-time Terra trust failure still ran migrations'
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. # A full install always runs the stage, whatever any recorded hash says.
build_fixture "$tmp/d" build_fixture "$tmp/d"
+12 -7
View File
@@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE'
# a comment, and a blank line follow # a comment, and a blank line follow
link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/AGENTS.md ~/.claude/CLAUDE.md
link agents/skills ~/.agents/skills linkdir agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills linkdir agents/skills ~/.claude/skills
copy plain.txt ~/.config/plain.txt copy plain.txt ~/.config/plain.txt
link missing.txt ~/.config/missing.txt link missing.txt ~/.config/missing.txt
@@ -102,11 +102,12 @@ PANAMA_USER_CONTENT=no run
# ── 2. Saying yes links, and keeps what was there ─────────────────────────── # ── 2. Saying yes links, and keeps what was there ───────────────────────────
# ~/.claude/skills is shared now: link-skills has already made it a real # Both skill homes are shared now: link-skills has already made them real
# directory and linked Panama's own skills into it. A linkdir entry has to land # directories and linked Panama's own skills into each. Personal linkdir
# beside those rather than replace the directory holding them. # entries land beside those rather than replacing either directory.
mkdir -p "$home/.claude/skills" mkdir -p "$home/.claude/skills" "$home/.agents/skills"
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped" ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped"
PANAMA_USER_CONTENT=yes run PANAMA_USER_CONTENT=yes run
@@ -114,8 +115,12 @@ PANAMA_USER_CONTENT=yes run
|| note 'CLAUDE.md was not replaced with a symlink into the checkout' || note 'CLAUDE.md was not replaced with a symlink into the checkout'
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \ [[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|| note 'the CLAUDE.md link does not resolve to the tracked file' || note 'the CLAUDE.md link does not resolve to the tracked file'
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \ [[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|| note 'the skills directory was not linked as a directory' || 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" ]] \ [[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|| note 'a linkdir entry replaced its destination directory with a symlink' || note 'a linkdir entry replaced its destination directory with a symlink'
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \ [[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
+11 -11
View File
@@ -13,7 +13,7 @@ and one file says where each piece goes.
| Path | Goes to | Why | | Path | Goes to | Why |
| --- | --- | --- | | --- | --- | --- |
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. | | `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. |
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | 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` | | | `agents/rules/` | `~/.claude/rules` | |
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. | | `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. | | `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
@@ -27,22 +27,22 @@ destination is empty. `linkdir` is the third, and it exists because one
destination is no longer only ours. destination is no longer only ours.
Panama ships its own agent skills now (`skills/`, linked by Panama ships its own agent skills now (`skills/`, linked by
`setup/scripts/link-skills`), and they go to `~/.claude/skills` — the same `setup/scripts/link-skills`), and they go to both skill homes. A directory
directory the personal ones went to as a single symlink. A directory cannot be cannot be a symlink to two places, so both personal entries use `linkdir`: each
a symlink to two places, so that entry became `linkdir`: the destination is a destination is a real directory, and each child of `user/agents/skills/` is
real directory, and each child of `user/agents/skills/` is linked into it linked into it individually.
individually. `~/.agents/skills` is still a whole-directory `link`, because
nothing else claims it.
Two consequences, recorded rather than fixed: Two consequences, recorded rather than fixed:
- **A personal skill named like a shipped one shadows it.** `link-user` runs - **A personal skill named like a shipped one shadows it.** `link-user` runs
after `link-skills` and displaces what it finds, so the personal one wins. after `link-skills` and displaces what it finds, so the personal one wins in
That is the intent, and it is the precedence Claude Code uses anyway. both skill homes.
- **A new personal skill needs a re-link to appear.** The whole-directory link - **A new personal skill needs a re-link to appear.** The whole-directory link
showed a newly created skill instantly; per-child links do not know about a showed a newly created skill instantly; per-child links do not know about a
child that did not exist when they were made. Run `panama update` or child that did not exist when they were made. Put new shared skills in
`setup/scripts/link-user` after adding one. `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 ## It is off unless you say yes
+37
View File
@@ -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 # Merge Requests
- Make sure titles follow conventions from the repo. - 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 reviews all of my code & up until recently, would also write all the dev reviews for all the stories I completed as well. Nowadays, I write them myself & he reviews those too.
- Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime. - Henry likes work to be very considerate & he always prefers solutions that result in 0 downtime.
- Despite the fact that Command Center does not have many users & the impact of it being down for a few minutes is small, Henry still leans on the side of solutions to problems that don't result in prod being down ever, even for just a few minutes during the build process. So our solutions should always keep that in mind. Any code that isn't considering everything & could result in a bug will probably be flagged by him, so its worthwhile to do right the first time! - Despite the fact that Command Center does not have many users & the impact of it being down for a few minutes is small, Henry still leans on the side of solutions to problems that don't result in prod being down ever, even for just a few minutes during the build process. So our solutions should always keep that in mind. Any code that isn't considering everything & could result in a bug will probably be flagged by him, so its worthwhile to do right the first time!
- Josh - Tech Lead for the APSCA project (apsca_next)
- On APSCA, Josh is my lead instead of Henry. He reviews my code and writes the dev reviews, spikes, and proposed schemas on the APSCA stories.
- His dev reviews are detailed and often include draft code, schema, and state machines. Treat them as the starting point for a story, not as a finished spec.
- Hunter Southworth - Engineering Manager / Senior Developer - Hunter Southworth - Engineering Manager / Senior Developer
- Kelson - Owner of Ksense - Kelson - Owner of Ksense
+21
View File
@@ -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
+104 -35
View File
@@ -1,6 +1,6 @@
--- ---
name: ticket name: ticket
description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, 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 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 - Don't check a checklist box (in `mr.md` or anywhere else) unless you actually verified
it. Leave it unchecked and say why in the notes rather than guessing. it. Leave it unchecked and say why in the notes rather than guessing.
## The prose bar (anything another person reads)
Everything this skill produces for someone else gets a pattern pass before it ships:
`mr.md`'s Summary and Additional Notes, every Jira field including the proof and
mitigation cells, and every deliverable document. Call the Skill tool with "unslop" and
apply its pattern detection.
- **Skip unslop's "Adding soul" section.** It asks for opinions, varied rhythm, and some
mess. That is right for an essay and wrong for a mitigation cell Henry is auditing.
These want plain, factual, and specific.
- **Never edit generated output.** The pre-MR handoff pasted into `mr.md` is the audit's
own words. If it reads badly, regenerate it, don't rewrite it.
- **Proof cells are prose too.** A cell that wraps three sentences around a filename is
writing, not a citation, and it picks up passive voice and jargon like any other
paragraph. Real examples caught this way: "affordance" for button, "vacuously" for
"for the wrong reason", "the test was confirmed able to fail" for "I emptied the
legacy render and confirmed the test fails".
- **Passive voice is the usual offender here,** not puffery. Technical writing drifts
into "X was verified" when "I verified X" is shorter and says who did it.
### Write as Gabriel, and never flag his own decisions
Everything that ships under his name is written **as him**, in first person. Never write
his name or any stand-in for it. Not "Gib decided", not "the author", not "per the
developer". When a sentence needs an actor, it is "I".
**Never frame one of his decisions as a deviation.** Do not write that something "was not
in this story's scope", that an AC or dev review said otherwise, that an earlier story
"should have caught it", or that a direction was "reversed". State what the change is and
why it belongs where it is, then stop.
He writes the tickets himself, so he is entitled to change them, and a dev review is a
proposal rather than a contract. Calling the change out reads as his own AI tattling on
him. It waves a red flag at a nothing burger, costs the PM time investigating a
non-issue, and leaves them assuming he did something wrong. The real story is normally
mundane and good: the better component already existed and cost almost nothing to use.
Compare, on KACP-23058:
> Bad: "This was not in this story's scope. KACP-23059 shipped that page still on the
> legacy tables, and Gib reversed the reuse-legacy direction on 2026-09-02."
>
> Good: "Both detail pages now share one set of grid components instead of two divergent
> implementations, so the move ships with the components it shares."
Same information, no flag attached. **If a decision feels big enough that you want to
narrate it, that is the signal to ask him about it before writing, not to annotate it in
the document.** Being aligned first is the point, and the writing should read as though
you always were.
This governs `mr.md`, `customfield_10260` and `10261`, every other Jira field, and PM
deliverables. It does not govern files only he reads. `plan.md`, `bugs.md`, audits and
working notes can name him and record who decided what, because that history is useful.
The House style punctuation rule above is the stricter one where the two overlap. Keep
it. Personal working files (`plan.md`, `ticket.md`) are exempt, nobody else reads them.
## Verification tools available ## Verification tools available
The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and The goal isn't "a plausible-sounding plan" or "code that compiles" — it's a plan and
@@ -226,15 +283,19 @@ method for producing accurate screenshots, not part of the deliverable's story.
Process, per deliverable: Process, per deliverable:
1. Write `<slug>.md`. 1. Write `<slug>.md`.
2. Write `<slug>.typ` from it, per the styling notes above. 2. Run the prose bar over `<slug>.md` before building anything from it. A spike
3. Compile: `typst compile <slug>.typ <slug>.pdf`. `typst` should already be on PATH; 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. 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 by page for longer documents). Check every page for real layout problems: text or
a table overflowing a page, an awkward page break splitting a table or diagram, a table overflowing a page, an awkward page break splitting a table or diagram,
cramped or excessive spacing, a diagram that rendered wrong. A successful compile cramped or excessive spacing, a diagram that rendered wrong. A successful compile
only means valid Typst, not that it looks right — actually look. only means valid Typst, not that it looks right — actually look.
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. PDF genuinely looks right, not just until it compiles without erroring.
If `.claude/docs/` is tracked by git in this repo (check with If `.claude/docs/` is tracked by git in this repo (check with
@@ -372,7 +433,7 @@ place from the start.
- Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it) - Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it)
- Resume implementation (some plan steps are already checked off / some commits - Resume implementation (some plan steps are already checked off / some commits
already exist on the ticket branch — pick up from the first unchecked step) already exist on the ticket branch — pick up from the first unchecked step)
- 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. Route to **Phase 1** or **Phase 2** accordingly.
- **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user - **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user
`mr.md` already exists at its path and ask whether they want you to refresh it `mr.md` already exists at its path and ask whether they want you to refresh it
@@ -620,8 +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 Order matters and is not just convenience. `pre-mr-review`'s output is a readiness
verdict that gets pasted verbatim into `mr.md`. Anything that runs after it verdict that gets pasted verbatim into `mr.md`. Anything that runs after it
invalidates that verdict by construction, and you end up rerunning it and rewriting invalidates that verdict by construction, and you end up rerunning it and rewriting
the handoff. Review first, fix, commit, and only then run `pre-mr-review` over the the handoff. Review first, fix, commit, and only then have the user
final tree. run `pre-mr-review` over the final tree.
- **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a - **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a
one line correction with no logic in it. Run it whenever the change adds or one line correction with no logic in it. Run it whenever the change adds or
@@ -671,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 in the current repo). If it doesn't, stop here, tell the user implementation and
local verification are done but this repo has no `pre-mr-review` skill to run, and local verification are done but this repo has no `pre-mr-review` skill to run, and
let them decide how to proceed. let them decide how to proceed.
7. Invoke the `pre-mr-review` skill. Read its verdict. 7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
- **Ready to Open MR**: continue to step 8. review run from the developer's separate work Claude account, so the audit on
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its record must come from there, not from this session. Instead:
own commit), then invoke `pre-mr-review` again. Repeat until the verdict is Ready 1. Tell the user the branch is ready for its pre-mr-review and ask them to run
to Open MR. Don't write `mr.md` before that verdict is reached. `/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, 8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
copied into this skill so it still works even though the original copied into this skill so it still works even though the original
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md` `.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
@@ -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: the audit's own words, not a retelling of them. Concretely:
- Never hand-write a handoff section, and never edit one in place. If it is - Never hand-write a handoff section, and never edit one in place. If it is
wrong, thin, or stale, fix the handoff file by rerunning `/pre-mr-review`, wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work
then re-paste. account to regenerate the handoff file, then re-paste.
- On EVERY rerun, replace the whole existing handoff block with the whole - 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 regenerated one. Do not patch the copy sitting in `mr.md` to match the new
head — that is how the two silently diverge, and the version the reviewer head — that is how the two silently diverge, and the version the reviewer
reads stops being the version the audit actually produced. reads stops being the version the audit actually produced.
@@ -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 content. A reviewer should be able to read the whole file in about a minute; if it
has grown past roughly 120 lines including the handoff, it has drifted. has grown past roughly 120 lines including the handoff, it has drifted.
Before saving, call the Skill tool with "unslop" and apply its pattern detection to Before saving, run the prose bar (see **The prose bar** above) over the Summary and
the Summary and Additional Notes ONLY: puffery, superficial -ing phrases, "not just Additional Notes ONLY. The pasted handoff is generated output and is never edited.
X but Y", vague attributions, rule-of-three padding. The pasted handoff is generated Henry reads every one of these, and prose that reads as machine-generated costs the
output and is never edited, per the rule above. Skip unslop's "Adding soul" section MR its credibility before anyone looks at the diff.
too — an MR description wants plain and factual, not voice. Henry reads every one of
these, and prose that reads as machine-generated costs the MR its credibility before
anyone looks at the diff.
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF: 9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`. render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
@@ -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 field-to-type map below is verified against the project's edit screens, don't PUT
a field to a type that doesn't carry it. a field to a type that doesn't carry it.
**Same prose bar as `mr.md`.** Every field authored here is read by the Lead and the **Run the prose bar** (see **The prose bar** above) over every field before rendering
PM. Run the `unslop` skill's pattern detection over the markdown before rendering it it to ADF, proof and mitigation cells included. Everything here is read by the Lead
to ADF, skipping its "Adding soul" section — Jira fields want plain, factual, and and the PM.
specific. This does not apply to proof cells, which are references to artifacts and
test names rather than prose.
**Proof first.** Before filling any proof column, capture working feature proof **Proof first.** Before filling any proof column, capture working feature proof
yourself wherever possible: run the app (`run` skill) and screenshot the real yourself wherever possible: run the app (`run` skill) and screenshot the real
@@ -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 handled and what covers it. Deliver the number asked for, not more, and pick the
ones a reviewer most benefits from. This field, not `mr.md`. ones a reviewer most benefits from. This field, not `mr.md`.
- `customfield_10261` User Story Patch Notes: the same change in user-facing words. - `customfield_10261` User Story Patch Notes: the same change in user-facing words.
- `customfield_10142` Working Feature Proof: ALWAYS fill this on a Story, never - `customfield_10142` Working Feature Proof: leave it alone. Write nothing here.
leave it empty. It renders as its own panel in the ticket's testing section, so This field is where Gib drops the proof attachments, and dragging files into it
an empty field reads as unfilled testing even when every test-table cell is replaces whatever text is sitting there, so a written list is destroyed by the
complete (this happened on KACP-23143). At minimum it lists the files staged in upload it was describing. Naming each artifact is already the job of the Test
`proof/` for Gib to attach, one line each saying what the artifact shows, plus a Cases proof cells, which reference it by filename. Step 10 tells Gib which files
pointer that per-row proof lives in the Test Cases table. A headline artifact (a to upload. Do not PUT this field on a Story.
demo capture, a before/after pair) leads the list when one exists.
**Spike** (deliverables, not code): no test or proof fields to fill. The output is **Spike** (deliverables, not code): no test or proof fields to fill. The output is
the deliverables directory, and step 10 tells Gib which files to upload where (the the deliverables directory, and step 10 tells Gib which files to upload where (the
@@ -16,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 Writing a second copy into `~/.claude/skills/` gives you two files that drift, and the drift is
silent because each harness only ever reads its own. One home, one symlink per harness that needs it. silent because each harness only ever reads its own. One home, one symlink per harness that needs it.
A skill that only makes sense inside one repo 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 ## Frontmatter
+6 -7
View File
@@ -28,14 +28,13 @@
link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/AGENTS.md ~/.claude/CLAUDE.md
link agents/AGENTS.md ~/.codex/AGENTS.md link agents/AGENTS.md ~/.codex/AGENTS.md
# Skills, at both paths that look for them. Linked so that a skill installed by # Skills, at both paths that look for them. Each tracked skill is linked from
# any tool lands in the checkout and `panama update` offers to commit it. # 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. # Both destinations also hold skills Panama itself ships, put there by the
# ~/.claude/skills also holds the skills Panama itself ships, put there by the # link-skills stage, so both are real directories with one link per skill. This
# link-skills stage, so it is a real directory with one link per skill -- and # stage runs after that one, so a personal skill wins a name collision.
# this stage runs after that one, so a personal skill wins a name collision. linkdir agents/skills ~/.agents/skills
link agents/skills ~/.agents/skills
linkdir agents/skills ~/.claude/skills linkdir agents/skills ~/.claude/skills
link agents/rules ~/.claude/rules link agents/rules ~/.claude/rules