Merge branch 'main' into codex/repo-audit-remediation-package-2
# Conflicts: # README.md # setup/scripts/install-packages
This commit is contained in:
@@ -9,7 +9,7 @@ You are editing a running desktop, not a codebase that gets deployed later. Ever
|
||||
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
|
||||
drives every rule below.
|
||||
|
||||
Read before large work, in this order:
|
||||
Before large work, read these in order:
|
||||
|
||||
- `README.md` — layout, the `panama` command, how installing and updating work
|
||||
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
|
||||
@@ -62,7 +62,8 @@ Every executable contract under `tests/` is classified in `tests/contracts.manif
|
||||
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
|
||||
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
|
||||
- Contracts run directly too: `tests/setup/interview-contract`.
|
||||
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
|
||||
- After changing `PreferenceSchema.qml` or
|
||||
`config/dot/quickshell/services/SettingsRoutes.qml`, regenerate:
|
||||
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
|
||||
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
|
||||
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/panama
|
||||
@@ -102,7 +102,7 @@ in order, without stopping again:
|
||||
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||
| `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||
| `link-skills` | Links the agent skills in `skills/` into `~/.agents/skills` and `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
||||
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||
@@ -235,10 +235,13 @@ server/ The server role: compose services (one directory per
|
||||
service), the nightly image updater, and its units. See
|
||||
server/README.md
|
||||
skills/ Agent skills for operating this desktop, linked into
|
||||
~/.claude/skills
|
||||
~/.agents/skills and ~/.claude/skills
|
||||
setup/
|
||||
apps/ Applications built from source, one file each
|
||||
lib/ Shared by more than one stage; the extras catalog reader
|
||||
keys/ Pinned signing keys, for publishers that ship no fetchable
|
||||
one; setup/keys/README.md records where each came from
|
||||
lib/ Shared by more than one stage; the extras catalog reader,
|
||||
the machine role, the verified ChatGPT repository
|
||||
packages/ One package per line; extras/ holds the optional categories
|
||||
scripts/ Run in order by ./install
|
||||
tests/ Contracts. See below
|
||||
@@ -247,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work
|
||||
|
||||
## Tests
|
||||
|
||||
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||
189 of them, under `tests/`. `tests/contracts.manifest` classifies every
|
||||
contract by the capabilities it needs. Run the hermetic set, or grant a
|
||||
specific external capability when automation needs it:
|
||||
|
||||
|
||||
+8
-2
@@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET}
|
||||
$PROGRAM upgrade
|
||||
$PROGRAM apps
|
||||
$PROGRAM app
|
||||
$PROGRAM app chatgpt-desktop
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -451,11 +450,16 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p
|
||||
|
||||
contract_paths() {
|
||||
local candidate
|
||||
# The manifest is kept in byte order, so both the discovery sort and the
|
||||
# comparison below have to be byte order too. A UTF-8 collation folds the
|
||||
# punctuation away -- `calendar_agenda_bridge_test.py` sorts before
|
||||
# `calendar-agenda-helper-contract` under en_US and after it under C -- and
|
||||
# a gate that passes or fails on the machine's LANG is not a gate.
|
||||
while IFS= read -r candidate; do
|
||||
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||||
printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}"
|
||||
done < <(find "$PANAMA_DIR/tests" -type f \
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||
}
|
||||
|
||||
contract_manifest_entries() {
|
||||
@@ -478,6 +482,8 @@ validate_contract_manifest() {
|
||||
require_contract_manifest || return 1
|
||||
|
||||
local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST"
|
||||
# Byte order, for the same reason contract_paths sorts in it.
|
||||
local LC_ALL=C
|
||||
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||||
local previous_path="" capability discovered
|
||||
local -a capability_list=() findings=()
|
||||
|
||||
@@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo
|
||||
alias nlc='nvim leetcode.nvim'
|
||||
alias clauded='claude --allow-dangerously-skip-permissions'
|
||||
alias claudedr='claude --allow-dangerously-skip-permissions --resume'
|
||||
alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude'
|
||||
alias commit-repo='git add -A && git commit -m "Update stuff" && git push'
|
||||
|
||||
# Docker Shortcuts
|
||||
|
||||
@@ -1805,7 +1805,7 @@ Singleton {
|
||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||
"codex-desktop", "md.obsidian.Obsidian",
|
||||
"chatgpt", "md.obsidian.Obsidian",
|
||||
"com.obsproject.Studio", "steam"
|
||||
]
|
||||
},
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# replace the community ChatGPT Desktop build with the official OpenAI package
|
||||
#
|
||||
# `panama app chatgpt-desktop` used to build a community wrapper (codex-desktop)
|
||||
# from the upstream macOS disk image, complete with a local rebuild daemon.
|
||||
# OpenAI ships an official Linux RPM now, and the installer takes that instead;
|
||||
# this repairs machines still carrying the community build. The official
|
||||
# package goes on before the community one comes off, so a failure part-way
|
||||
# leaves the machine with an app, never without one.
|
||||
#
|
||||
# Rules, because the runner cannot enforce them:
|
||||
#
|
||||
# * Safe to run twice. The marker records success, not intent.
|
||||
# * Tolerant of the repair already being correct -- the user may have fixed
|
||||
# it by hand, or a later ./install may have put it back.
|
||||
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
|
||||
# so the password prompt names the repair.
|
||||
# * Exit non-zero to be retried at the next login. Exit zero only when the
|
||||
# machine is genuinely in the state this describes.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
|
||||
# Machines that never had the community build are already correct. The official
|
||||
# app is the installer's job, not this one's.
|
||||
rpm -q codex-desktop >/dev/null 2>&1 || exit 0
|
||||
|
||||
# The same verified repository the installer establishes: the pinned signing
|
||||
# key, then a repository that names it, so dnf checks OpenAI's signature before
|
||||
# root installs anything. See setup/lib/chatgpt-package.
|
||||
# shellcheck source=../setup/lib/chatgpt-package
|
||||
source "$PANAMA_PATH/setup/lib/chatgpt-package"
|
||||
|
||||
sudo_cmd=(sudo)
|
||||
if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then
|
||||
sudo_cmd=(
|
||||
"$PANAMA_PATH/bin/panama-sudo" --reason
|
||||
"Replacing the community-built ChatGPT Desktop (codex-desktop) with the official OpenAI package"
|
||||
--
|
||||
)
|
||||
fi
|
||||
|
||||
# The official package first, so the machine is never left without one.
|
||||
if ! rpm -q chatgpt >/dev/null 2>&1; then
|
||||
chatgpt_install_repository "${sudo_cmd[@]}"
|
||||
"${sudo_cmd[@]}" dnf install -y chatgpt
|
||||
fi
|
||||
|
||||
# The community package's updater is a user unit; stop it before dnf removes
|
||||
# the unit file out from under it. Removal also takes the app in /opt, both
|
||||
# binaries, and the polkit policy the local rebuilds needed.
|
||||
systemctl --user disable --now codex-update-manager.service 2>/dev/null || true
|
||||
"${sudo_cmd[@]}" dnf remove -y codex-desktop
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
# The rebuild state the updater kept; the official package needs none of it.
|
||||
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/codex-update-manager" \
|
||||
"${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes" \
|
||||
"${XDG_CONFIG_HOME:-$HOME/.config}/codex-update-manager" \
|
||||
"${XDG_STATE_HOME:-$HOME/.local/state}/codex-update-manager"
|
||||
|
||||
echo "Replaced the community codex-desktop build with the official chatgpt package."
|
||||
@@ -1,19 +0,0 @@
|
||||
# ChatGPT Desktop.
|
||||
#
|
||||
# OpenAI ships macOS and Windows only. This is a community wrapper that converts
|
||||
# the upstream macOS disk image into a Linux Electron app and packages it as an
|
||||
# RPM, so the installed result is again something dnf owns.
|
||||
#
|
||||
# Same exception, same reason: there is no packaged form to prefer. Nothing is
|
||||
# pinned; `bootstrap-native` fetches the current upstream image each time and
|
||||
# fails loudly when it cannot.
|
||||
|
||||
description="ChatGPT Desktop, built into a Fedora RPM"
|
||||
repo="https://github.com/ilysenko/codex-desktop-linux.git"
|
||||
|
||||
# bootstrap-native installs build dependencies, builds, packages, and installs
|
||||
# the newest artifact -- so unlike the Claude build there is no separate install
|
||||
# step to do here.
|
||||
build() {
|
||||
make bootstrap-native
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Pinned signing keys
|
||||
|
||||
A key lands here when a publisher signs what Panama installs but does not
|
||||
publish the key, or its fingerprint, anywhere an install could fetch and check
|
||||
them first. Pinning the key is what lets `dnf` verify a download before root
|
||||
ever sees it.
|
||||
|
||||
Nothing here is a secret. These are public keys, and the reason to track them
|
||||
is that a *changed* one should be a merge request somebody reads, not a silent
|
||||
change of who is trusted.
|
||||
|
||||
## `RPM-GPG-KEY-chatgpt`
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Fingerprint | `3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4` |
|
||||
| User ID | `Codex Linux Repository` |
|
||||
| Signs | the `chatgpt` package and the repository metadata at `https://persistent.oaistatic.com/codex-app-prod/linux/rpm/$basearch` |
|
||||
| Used by | `setup/lib/chatgpt-package` |
|
||||
|
||||
Captured on 2026-08-27 from a machine where the official package had been
|
||||
installed, at `/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt`, where the package's own
|
||||
root scriptlet writes it. It is the key that signed both the installed
|
||||
`chatgpt` package and the live `repodata/repomd.xml.asc`.
|
||||
|
||||
Be honest about what that is worth: OpenAI's documented instructions
|
||||
(<https://learn.chatgpt.com/docs/linux/linux-app>) are to download an RPM and
|
||||
install it, and they publish no key URL and no fingerprint to compare against.
|
||||
So this is trust established on first use and then held, not trust verified
|
||||
against the publisher. Held is the part that matters -- from here every machine
|
||||
checks the same fingerprint, and a swapped download fails instead of installing.
|
||||
|
||||
To re-derive the fingerprint from the file:
|
||||
|
||||
```bash
|
||||
gpg --show-keys --with-colons setup/keys/RPM-GPG-KEY-chatgpt \
|
||||
| awk -F: '$1 == "fpr" { print $10; exit }'
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8
|
||||
3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd
|
||||
GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe
|
||||
hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw
|
||||
lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J
|
||||
vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k
|
||||
4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/
|
||||
7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9
|
||||
og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9
|
||||
WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W
|
||||
jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB
|
||||
tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo
|
||||
SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ
|
||||
SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev
|
||||
SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe
|
||||
IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF
|
||||
VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo
|
||||
pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89
|
||||
6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu
|
||||
i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS
|
||||
uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E
|
||||
I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV
|
||||
8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712
|
||||
Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno=
|
||||
=Y+3h
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -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
|
||||
}
|
||||
@@ -89,6 +89,11 @@ source "$PANAMA_PATH/setup/lib/extras-catalog"
|
||||
source "$PANAMA_PATH/setup/lib/machine-role"
|
||||
ROLE="$(panama_role)"
|
||||
|
||||
# Establishing the verified ChatGPT repository, shared with the migration that
|
||||
# replaces the community build, so neither can install it a less careful way.
|
||||
# shellcheck source=../lib/chatgpt-package
|
||||
source "$PANAMA_PATH/setup/lib/chatgpt-package"
|
||||
|
||||
# One list, installed the way every list is installed: --skip-unavailable so a
|
||||
# single rotted name cannot cost the transaction, then report_missing so a
|
||||
# skipped name is a warning somebody reads.
|
||||
@@ -1299,6 +1304,28 @@ if ! install_claude_desktop_if_trusted; then
|
||||
softly_failed+=("Claude Desktop")
|
||||
fi
|
||||
|
||||
# ChatGPT Desktop: OpenAI ships an official Linux RPM now. Panama used to build
|
||||
# a community wrapper from the macOS disk image -- it was `panama app
|
||||
# chatgpt-desktop` -- because no packaged form existed; that build froze often
|
||||
# and carried its own local rebuild daemon. The official package is strictly
|
||||
# better: it comes from a repository, so it upgrades with every other package
|
||||
# from then on.
|
||||
#
|
||||
# The repository and its signing key are established first, from the copy
|
||||
# pinned in setup/keys/, so dnf verifies the metadata and the package before
|
||||
# either reaches root. Upstream's own instructions do not allow that -- see
|
||||
# setup/lib/chatgpt-package for why they are not followed here.
|
||||
if rpm -q chatgpt >/dev/null 2>&1; then
|
||||
log "ChatGPT Desktop already installed"
|
||||
elif ! chatgpt_install_repository sudo; then
|
||||
log "Could not establish the verified ChatGPT repository; skipping"
|
||||
softly_failed+=("ChatGPT Desktop")
|
||||
else
|
||||
log "Installing ChatGPT Desktop..."
|
||||
sudo dnf install -y chatgpt > /dev/null \
|
||||
|| { log "ChatGPT Desktop install failed; skipping"; softly_failed+=("ChatGPT Desktop"); }
|
||||
fi
|
||||
|
||||
# The RPM ships rustdesk.service already enabled, which is what provides
|
||||
# unattended access; Panama deliberately does not start it a second time.
|
||||
install_rustdesk || true
|
||||
|
||||
+22
-20
@@ -11,12 +11,11 @@
|
||||
# this repository, not anybody's personal content, so a stranger who clones
|
||||
# Panama wants it for exactly the same reason its author does.
|
||||
#
|
||||
# ~/.claude/skills was a single symlink into user/agents/skills until now, and
|
||||
# a directory cannot be two things at once. So the destination becomes a real
|
||||
# directory and every skill -- shipped here, personal from user/ -- is linked
|
||||
# into it one at a time. link-user runs after this stage on purpose: it links
|
||||
# last, so a personal skill named like a shipped one wins, which is the
|
||||
# precedence Claude Code itself uses.
|
||||
# ~/.agents/skills and ~/.claude/skills may each start as a single symlink into
|
||||
# user/agents/skills, but a directory cannot point at personal and shipped
|
||||
# skills at once. Both destinations become real directories with one link per
|
||||
# skill. link-user runs after this stage on purpose, so a personal skill named
|
||||
# like a shipped one wins in every agent runtime.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -25,7 +24,7 @@ log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
SKILLS_DIR="$PANAMA_PATH/skills"
|
||||
PANAMA_OLD="$PANAMA_PATH/config/old"
|
||||
DESTINATION="$HOME/.claude/skills"
|
||||
DESTINATIONS=("$HOME/.agents/skills" "$HOME/.claude/skills")
|
||||
|
||||
[[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; }
|
||||
|
||||
@@ -51,27 +50,30 @@ displace() {
|
||||
log "Moved existing $destination to $backup"
|
||||
}
|
||||
|
||||
# The destination itself has to be a real directory before anything can be
|
||||
# linked into it. An old whole-directory symlink is removed; a regular file
|
||||
# somebody left at this path is kept, in config/old/.
|
||||
mkdir -p "$(dirname "$DESTINATION")"
|
||||
if [[ -L "$DESTINATION" ]]; then
|
||||
rm -f "$DESTINATION"
|
||||
log "Removed the old $DESTINATION symlink; skills are linked one by one now"
|
||||
elif [[ -e "$DESTINATION" && ! -d "$DESTINATION" ]]; then
|
||||
displace "$DESTINATION"
|
||||
fi
|
||||
mkdir -p "$DESTINATION"
|
||||
# Each destination has to be a real directory before anything can be linked
|
||||
# into it. An old whole-directory symlink is removed; a regular file somebody
|
||||
# left at the path is kept in config/old/.
|
||||
for destination in "${DESTINATIONS[@]}"; do
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
if [[ -L "$destination" ]]; then
|
||||
rm -f "$destination"
|
||||
log "Removed the old $destination symlink; skills are linked one by one now"
|
||||
elif [[ -e "$destination" && ! -d "$destination" ]]; then
|
||||
displace "$destination"
|
||||
fi
|
||||
mkdir -p "$destination"
|
||||
done
|
||||
|
||||
linked=0
|
||||
for skill in "$SKILLS_DIR"/*; do
|
||||
[[ -e "$skill" ]] || continue
|
||||
name="$(basename "$skill")"
|
||||
target="$DESTINATION/$name"
|
||||
|
||||
for destination in "${DESTINATIONS[@]}"; do
|
||||
target="$destination/$name"
|
||||
displace "$target"
|
||||
ln -s "$skill" "$target"
|
||||
log "Linked skills/$name → $target"
|
||||
done
|
||||
linked=$(( linked + 1 ))
|
||||
done
|
||||
|
||||
|
||||
@@ -247,6 +247,7 @@ hermetic tests/server/containers-shape-contract
|
||||
hermetic tests/server/panama-server-contract
|
||||
hermetic tests/setup/apps-contract
|
||||
hermetic tests/setup/boot-contract
|
||||
hermetic tests/setup/chatgpt-package-contract
|
||||
hermetic tests/setup/contract-manifest-contract
|
||||
hermetic tests/setup/crash-watch-contract
|
||||
hermetic tests/setup/desktop-first-contract
|
||||
|
||||
@@ -32,8 +32,6 @@ panama="$repo_dir/bin/panama"
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; }
|
||||
|
||||
shopt -s nullglob
|
||||
definitions=("$apps_dir"/*)
|
||||
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The one download that gets to run as root, and how it earns that.
|
||||
#
|
||||
# OpenAI publishes no signing key and no fingerprint that a first install could
|
||||
# fetch and compare against: the documented instructions are to download an RPM
|
||||
# and install it, and that RPM's own root scriptlet is what decides afterwards
|
||||
# which repository and which key the machine will trust. Panama pins the key
|
||||
# instead -- setup/keys/ carries it, setup/lib/chatgpt-package verifies the copy
|
||||
# and writes the repository -- so dnf checks a signature before root sees a byte
|
||||
# of it.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. The pinned key is the key the library says it is. Everything else here
|
||||
# is worthless if this drifts, and a changed key must be a failing test
|
||||
# somebody reads rather than a quiet change of publisher.
|
||||
# 2. A pinned key that is missing, unreadable, or simply not that key stops
|
||||
# the install and leaves the machine untouched. Failing closed is the
|
||||
# whole point; falling back to installing anyway would be worse than
|
||||
# never having checked.
|
||||
# 3. What it writes actually enforces the check: gpgcheck and repo_gpgcheck
|
||||
# on, and the gpgkey pointing at the key it just installed.
|
||||
# 4. Both callers go through it, and neither hands root a downloaded RPM.
|
||||
# The installer and the codex-desktop migration install `chatgpt` by name
|
||||
# from that repository, which is what makes the signature mandatory.
|
||||
#
|
||||
# Hermetic: the key file is read locally, root is a stub that records what it
|
||||
# was asked to do, and the destinations are redirected into a temporary
|
||||
# directory. Nothing here contacts OpenAI or touches /etc.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
library="$repo_dir/setup/lib/chatgpt-package"
|
||||
installer="$repo_dir/setup/scripts/install-packages"
|
||||
migration="$repo_dir/migrations/1787804505.sh"
|
||||
pinned_key="$repo_dir/setup/keys/RPM-GPG-KEY-chatgpt"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -r "$library" ]] || {
|
||||
printf 'chatgpt package contract: %s is missing\n' "$library" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v gpg >/dev/null 2>&1 || {
|
||||
printf 'chatgpt package contract: gpg is required to read the pinned key\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# Root, as a recording stub. It logs the command and then runs it for real,
|
||||
# which is safe because every destination below is redirected into $work.
|
||||
stub="$work/bin"
|
||||
mkdir -p "$stub"
|
||||
cat >"$stub/sudo" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >>"$SUDO_RECORD"
|
||||
exec "$@"
|
||||
STUB
|
||||
cat >"$stub/rpmkeys" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >>"$RPMKEYS_RECORD"
|
||||
STUB
|
||||
chmod +x "$stub/sudo" "$stub/rpmkeys"
|
||||
export PATH="$stub:$PATH"
|
||||
|
||||
# ── 1. The pinned key is the pinned key ─────────────────────────────────────
|
||||
|
||||
if [[ ! -r "$pinned_key" ]]; then
|
||||
note 'setup/keys/RPM-GPG-KEY-chatgpt is missing, so nothing can be verified'
|
||||
else
|
||||
declared="$(grep -oP '(?<=^CHATGPT_KEY_FINGERPRINT=")[0-9A-F]+' "$library" | head -1)"
|
||||
actual="$(gpg --show-keys --with-colons "$pinned_key" 2>/dev/null \
|
||||
| awk -F: '$1 == "fpr" { print $10; exit }')"
|
||||
[[ -n "$declared" ]] \
|
||||
|| note 'the library pins no fingerprint, so any key file would be accepted'
|
||||
[[ -n "$actual" ]] \
|
||||
|| note 'the pinned key file does not parse as a public key'
|
||||
[[ "$declared" == "$actual" ]] \
|
||||
|| note "the pinned key is $actual but the library expects $declared"
|
||||
grep -q 'RPM-GPG-KEY-chatgpt' "$repo_dir/setup/keys/README.md" 2>/dev/null \
|
||||
|| note 'setup/keys/README.md does not record where the pinned key came from'
|
||||
grep -q "$actual" "$repo_dir/setup/keys/README.md" 2>/dev/null \
|
||||
|| note 'setup/keys/README.md records a fingerprint other than the key it ships'
|
||||
fi
|
||||
|
||||
# One attempt, against redirected destinations and a recording root. Every
|
||||
# variable the library exposes is set here rather than in the caller's shell,
|
||||
# so a case cannot leak into the next one.
|
||||
attempt() {
|
||||
local dir="$1" panama_path="$2" fingerprint="${3:-}"
|
||||
mkdir -p "$dir"
|
||||
(
|
||||
export SUDO_RECORD="$dir/sudo.log" RPMKEYS_RECORD="$dir/rpmkeys.log"
|
||||
: >"$SUDO_RECORD"
|
||||
: >"$RPMKEYS_RECORD"
|
||||
PANAMA_PATH="$panama_path"
|
||||
# shellcheck source=/dev/null
|
||||
source "$library"
|
||||
CHATGPT_KEY_FILE="$dir/pki/RPM-GPG-KEY-chatgpt"
|
||||
CHATGPT_REPO_FILE="$dir/repos/chatgpt.repo"
|
||||
mkdir -p "$dir/repos"
|
||||
[[ -z "$fingerprint" ]] || CHATGPT_KEY_FINGERPRINT="$fingerprint"
|
||||
chatgpt_install_repository sudo
|
||||
) >"$dir/out" 2>&1
|
||||
}
|
||||
|
||||
# ── 2. It fails closed ──────────────────────────────────────────────────────
|
||||
|
||||
# A checkout with no pinned key at all.
|
||||
empty="$work/no-key"
|
||||
mkdir -p "$empty/checkout/setup/keys"
|
||||
attempt "$empty" "$empty/checkout" \
|
||||
&& note 'a missing pinned key still established the repository'
|
||||
[[ ! -e "$empty/repos/chatgpt.repo" ]] \
|
||||
|| note 'a missing pinned key still wrote a repository file'
|
||||
grep -qi 'missing' "$empty/out" \
|
||||
|| note 'a missing pinned key does not say so'
|
||||
|
||||
# A key file that is not a key.
|
||||
garbage="$work/garbage-key"
|
||||
mkdir -p "$garbage/checkout/setup/keys"
|
||||
printf 'not a key\n' >"$garbage/checkout/setup/keys/RPM-GPG-KEY-chatgpt"
|
||||
attempt "$garbage" "$garbage/checkout" \
|
||||
&& note 'an unreadable pinned key still established the repository'
|
||||
[[ ! -e "$garbage/repos/chatgpt.repo" ]] \
|
||||
|| note 'an unreadable pinned key still wrote a repository file'
|
||||
|
||||
# The real key, against a fingerprint that is not its own -- the shape a
|
||||
# substituted publisher would take.
|
||||
wrong="$work/wrong-fingerprint"
|
||||
attempt "$wrong" "$repo_dir" '0000000000000000000000000000000000000000' \
|
||||
&& note 'a key that does not match the pinned fingerprint was accepted'
|
||||
[[ ! -e "$wrong/repos/chatgpt.repo" ]] \
|
||||
|| note 'a fingerprint mismatch still wrote a repository file'
|
||||
[[ ! -s "$wrong/rpmkeys.log" ]] \
|
||||
|| note 'a fingerprint mismatch still imported the key into the rpm keyring'
|
||||
|
||||
# ── 3. What it writes enforces the check ────────────────────────────────────
|
||||
|
||||
good="$work/verified"
|
||||
if ! attempt "$good" "$repo_dir"; then
|
||||
note "the pinned key was rejected: $(cat "$good/out")"
|
||||
else
|
||||
repo_file="$good/repos/chatgpt.repo"
|
||||
key_file="$good/pki/RPM-GPG-KEY-chatgpt"
|
||||
|
||||
cmp -s "$key_file" "$pinned_key" \
|
||||
|| note 'the installed key is not the pinned key'
|
||||
grep -q 'import' "$good/rpmkeys.log" \
|
||||
|| note 'the verified key was never imported, so dnf has nothing to check against'
|
||||
|
||||
grep -qx 'gpgcheck=1' "$repo_file" \
|
||||
|| note 'the repository does not set gpgcheck=1, so package signatures go unchecked'
|
||||
grep -qx 'repo_gpgcheck=1' "$repo_file" \
|
||||
|| note 'the repository does not set repo_gpgcheck=1, so the metadata goes unchecked'
|
||||
grep -qx "gpgkey=file://$key_file" "$repo_file" \
|
||||
|| note 'the repository does not point gpgkey at the key that was just installed'
|
||||
grep -q 'baseurl=https://' "$repo_file" \
|
||||
|| note 'the repository has no https base URL'
|
||||
fi
|
||||
|
||||
# ── 4. Both callers go through it ───────────────────────────────────────────
|
||||
|
||||
for caller in "$installer" "$migration"; do
|
||||
name="${caller#"$repo_dir"/}"
|
||||
[[ -r "$caller" ]] || { note "$name is missing"; continue; }
|
||||
|
||||
grep -q 'setup/lib/chatgpt-package' "$caller" \
|
||||
|| note "$name does not source the verified install library"
|
||||
grep -q 'chatgpt_install_repository' "$caller" \
|
||||
|| note "$name does not establish the verified repository before installing"
|
||||
grep -qE 'dnf install -y chatgpt\b' "$caller" \
|
||||
|| note "$name does not install chatgpt by name from that repository"
|
||||
|
||||
# The shape this contract exists to keep out: fetch an RPM, hand it to
|
||||
# root, and let its scriptlet decide what the machine trusts afterwards.
|
||||
grep -qE 'curl.*chatgpt.*\.rpm' "$caller" \
|
||||
&& note "$name downloads a ChatGPT RPM instead of installing it from the verified repository"
|
||||
grep -qE 'dnf install[^|]*\$\{?chatgpt_rpm' "$caller" \
|
||||
&& note "$name installs a downloaded ChatGPT RPM as root"
|
||||
done
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
printf 'chatgpt package contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'chatgpt package contract: PASS\n'
|
||||
@@ -11,16 +11,22 @@ manifest="$repo_dir/tests/contracts.manifest"
|
||||
|
||||
discover_contracts() {
|
||||
discovered_contracts=()
|
||||
# Byte order, exactly as the runner discovers them. A UTF-8 collation folds
|
||||
# the punctuation away and reorders the pairs that differ only by `-` and
|
||||
# `_`, so a manifest correct here would be wrong on a machine with a
|
||||
# different LANG.
|
||||
while IFS= read -r path; do
|
||||
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||||
discovered_contracts+=("tests/${path#"$repo_dir/tests/"}")
|
||||
done < <(find "$repo_dir/tests" -type f \
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
-not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort)
|
||||
}
|
||||
|
||||
validate_manifest() {
|
||||
local candidate="$1"
|
||||
local -n expected_contracts="$2"
|
||||
# Byte order, for the same reason discover_contracts sorts in it.
|
||||
local LC_ALL=C
|
||||
local line capabilities path extra previous_comment="" previous_was_comment=0
|
||||
local -a capability_list=()
|
||||
local -A manifest_paths=() capability_counts=()
|
||||
|
||||
@@ -336,8 +336,9 @@ expect_failure load_installer_provenance "$parser_fixture"
|
||||
installer_fixture="$test_tmp/installer-fixture"
|
||||
mkdir -p "$installer_fixture/setup/lib" "$installer_fixture/setup/provenance/keys" \
|
||||
"$installer_fixture/setup/scripts"
|
||||
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/extras-catalog" \
|
||||
"$repo_dir/setup/lib/machine-role" "$installer_fixture/setup/lib/"
|
||||
cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/chatgpt-package" \
|
||||
"$repo_dir/setup/lib/extras-catalog" "$repo_dir/setup/lib/machine-role" \
|
||||
"$installer_fixture/setup/lib/"
|
||||
cp "$config" "$installer_fixture/setup/provenance/installers.conf"
|
||||
cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/"
|
||||
sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \
|
||||
|
||||
+41
-20
@@ -2,7 +2,7 @@
|
||||
|
||||
# The manual this machine hands an agent.
|
||||
#
|
||||
# skills/ and .claude/skills/panama exist because an agent asked to do anything
|
||||
# skills/ and .agents/skills/panama exist because an agent asked to do anything
|
||||
# on a Panama desktop will otherwise infer it from the source and get half of it
|
||||
# wrong. That only helps if what the skills say is true -- and a skill is worse
|
||||
# than no skill when it is stale, because an agent believes it verbatim and does
|
||||
@@ -16,7 +16,7 @@
|
||||
# That is a convention on the prose -- name things exactly, in backticks --
|
||||
# and it is how they should be written anyway.
|
||||
# 3. The delivery works: link-skills is a stage, in the right place, and the
|
||||
# personal manifest hands ~/.claude/skills over to the linkdir kind.
|
||||
# personal manifest hands both shared skill homes to the linkdir kind.
|
||||
#
|
||||
# Sections 1 and 2 report clearly and keep going when a skill is not written
|
||||
# yet, so this contract is useful while the skills are still being authored.
|
||||
@@ -32,9 +32,21 @@ manifest="$repo_dir/user/manifest"
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
# The three, and where each is delivered from. The two under skills/ are shipped
|
||||
# to every machine; the third is project-level and needs no delivery at all.
|
||||
SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama)
|
||||
# Every directory under skills/ ships to every machine. The project-level
|
||||
# panama skill stays in this repository and needs no home-directory delivery.
|
||||
SKILL_DIRS=()
|
||||
for directory in "$repo_dir"/skills/*; do
|
||||
[[ -d "$directory" ]] && SKILL_DIRS+=("${directory#"$repo_dir"/}")
|
||||
done
|
||||
SKILL_DIRS+=(.agents/skills/panama)
|
||||
|
||||
# The project skill has one agent-neutral source. Claude gets a compatibility
|
||||
# symlink, while Codex and other Agent Skills readers use .agents directly.
|
||||
[[ -L "$repo_dir/.claude/skills/panama" ]] \
|
||||
|| note '.claude/skills/panama is not a compatibility symlink to the agent-neutral source'
|
||||
[[ "$(readlink -f "$repo_dir/.claude/skills/panama" 2>/dev/null)" == \
|
||||
"$(readlink -f "$repo_dir/.agents/skills/panama" 2>/dev/null)" ]] \
|
||||
|| note '.claude and .agents resolve the project panama skill differently'
|
||||
|
||||
# ── 1. Each skill loads ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,7 +66,7 @@ for relative in "${SKILL_DIRS[@]}"; do
|
||||
present+=("$directory")
|
||||
|
||||
# Frontmatter is the first --- delimited block, and a skill without one is
|
||||
# not a skill: Claude Code skips the directory entirely.
|
||||
# not a skill: agent loaders skip the directory entirely.
|
||||
frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
|
||||
if [[ -z "$frontmatter" ]]; then
|
||||
note "$relative/SKILL.md does not open with a --- frontmatter block"
|
||||
@@ -207,12 +219,12 @@ else
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# A checkout and a home of its own. Never the real ones: ~/.claude/skills on
|
||||
# this machine is somebody's live agent setup, and a contract that broke it
|
||||
# mid-session would be worse than the bug it was looking for.
|
||||
# A checkout and a home of its own. Never the real ones: these are somebody's
|
||||
# live agent setup, and a contract that broke them mid-session would be worse
|
||||
# than the bug it was looking for.
|
||||
checkout="$work/Panama"
|
||||
home="$work/home"
|
||||
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude"
|
||||
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents"
|
||||
cp "$linker" "$checkout/setup/scripts/link-skills"
|
||||
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
|
||||
|
||||
@@ -220,6 +232,7 @@ else
|
||||
# symlink, which is what link-user used to leave here.
|
||||
mkdir -p "$work/personal"
|
||||
ln -s "$work/personal" "$home/.claude/skills"
|
||||
ln -s "$work/personal" "$home/.agents/skills"
|
||||
|
||||
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
|
||||
|
||||
@@ -231,22 +244,30 @@ else
|
||||
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
|
||||
[[ -L "$home/.claude/skills/shipped" ]] \
|
||||
|| note 'link-skills did not link each shipped skill as a child of ~/.claude/skills'
|
||||
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
||||
|| note 'link-skills left ~/.agents/skills a symlink, so shipped and personal skills cannot coexist'
|
||||
[[ -L "$home/.agents/skills/shipped" ]] \
|
||||
|| note 'link-skills did not link each shipped skill as a child of ~/.agents/skills'
|
||||
grep -q 'Agent skills: 1 linked' "$work/log" \
|
||||
|| note 'link-skills does not report how many skills it linked'
|
||||
|
||||
# A real directory at a shipped skill's name is somebody's work: it moves to
|
||||
# config/old rather than being deleted, the same promise the other stages
|
||||
# make. A symlink is not, and must not accumulate there.
|
||||
rm "$home/.claude/skills/shipped"
|
||||
mkdir -p "$home/.claude/skills/shipped"
|
||||
printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md"
|
||||
mkdir -p "$home/.claude/skills/untouched"
|
||||
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||
rm -f "$skill_home/shipped"
|
||||
mkdir -p "$skill_home/shipped"
|
||||
printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md"
|
||||
mkdir -p "$skill_home/untouched"
|
||||
done
|
||||
|
||||
run
|
||||
grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \
|
||||
|| note 'link-skills destroyed a real skill instead of moving it to config/old'
|
||||
[[ -d "$home/.claude/skills/untouched" ]] \
|
||||
|| note 'link-skills removed a skill it does not ship'
|
||||
[[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \
|
||||
|| note 'link-skills did not preserve real skills from both agent homes'
|
||||
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
||||
[[ -d "$skill_home/untouched" ]] \
|
||||
|| note "link-skills removed an unshipped skill from $skill_home"
|
||||
done
|
||||
|
||||
before="$(find "$checkout/config/old" | wc -l)"
|
||||
run
|
||||
@@ -287,8 +308,8 @@ fi
|
||||
|
||||
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \
|
||||
|| note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory'
|
||||
grep -qE '^\s*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
||||
|| note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path'
|
||||
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
||||
|| note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills'
|
||||
grep -q 'linkdir)' "$user_linker" \
|
||||
|| note 'link-user does not implement the linkdir kind the manifest asks for'
|
||||
grep -q 'linkdir' "$repo_dir/user/README.md" \
|
||||
|
||||
@@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE'
|
||||
# a comment, and a blank line follow
|
||||
|
||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||
link agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.claude/skills
|
||||
copy plain.txt ~/.config/plain.txt
|
||||
link missing.txt ~/.config/missing.txt
|
||||
@@ -102,11 +102,12 @@ PANAMA_USER_CONTENT=no run
|
||||
|
||||
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
|
||||
|
||||
# ~/.claude/skills is shared now: link-skills has already made it a real
|
||||
# directory and linked Panama's own skills into it. A linkdir entry has to land
|
||||
# beside those rather than replace the directory holding them.
|
||||
mkdir -p "$home/.claude/skills"
|
||||
# Both skill homes are shared now: link-skills has already made them real
|
||||
# directories and linked Panama's own skills into each. Personal linkdir
|
||||
# entries land beside those rather than replacing either directory.
|
||||
mkdir -p "$home/.claude/skills" "$home/.agents/skills"
|
||||
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
|
||||
ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped"
|
||||
|
||||
PANAMA_USER_CONTENT=yes run
|
||||
|
||||
@@ -114,8 +115,12 @@ PANAMA_USER_CONTENT=yes run
|
||||
|| note 'CLAUDE.md was not replaced with a symlink into the checkout'
|
||||
[[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \
|
||||
|| note 'the CLAUDE.md link does not resolve to the tracked file'
|
||||
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||
|| note 'the skills directory was not linked as a directory'
|
||||
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
||||
|| note 'a linkdir entry replaced ~/.agents/skills with a symlink'
|
||||
[[ -L "$home/.agents/skills/example" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||
|| note 'a linkdir entry did not publish the personal skill for agent-neutral readers'
|
||||
[[ -L "$home/.agents/skills/shipped" ]] \
|
||||
|| note 'a linkdir entry removed the shipped skill from ~/.agents/skills'
|
||||
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|
||||
|| note 'a linkdir entry replaced its destination directory with a symlink'
|
||||
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
|
||||
|
||||
+11
-11
@@ -13,7 +13,7 @@ and one file says where each piece goes.
|
||||
| Path | Goes to | Why |
|
||||
| --- | --- | --- |
|
||||
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. |
|
||||
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | A skill installed on any machine lands in the checkout. The Claude path is `linkdir` — see below. |
|
||||
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. |
|
||||
| `agents/rules/` | `~/.claude/rules` | |
|
||||
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
|
||||
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
|
||||
@@ -27,22 +27,22 @@ destination is empty. `linkdir` is the third, and it exists because one
|
||||
destination is no longer only ours.
|
||||
|
||||
Panama ships its own agent skills now (`skills/`, linked by
|
||||
`setup/scripts/link-skills`), and they go to `~/.claude/skills` — the same
|
||||
directory the personal ones went to as a single symlink. A directory cannot be
|
||||
a symlink to two places, so that entry became `linkdir`: the destination is a
|
||||
real directory, and each child of `user/agents/skills/` is linked into it
|
||||
individually. `~/.agents/skills` is still a whole-directory `link`, because
|
||||
nothing else claims it.
|
||||
`setup/scripts/link-skills`), and they go to both skill homes. A directory
|
||||
cannot be a symlink to two places, so both personal entries use `linkdir`: each
|
||||
destination is a real directory, and each child of `user/agents/skills/` is
|
||||
linked into it individually.
|
||||
|
||||
Two consequences, recorded rather than fixed:
|
||||
|
||||
- **A personal skill named like a shipped one shadows it.** `link-user` runs
|
||||
after `link-skills` and displaces what it finds, so the personal one wins.
|
||||
That is the intent, and it is the precedence Claude Code uses anyway.
|
||||
after `link-skills` and displaces what it finds, so the personal one wins in
|
||||
both skill homes.
|
||||
- **A new personal skill needs a re-link to appear.** The whole-directory link
|
||||
showed a newly created skill instantly; per-child links do not know about a
|
||||
child that did not exist when they were made. Run `panama update` or
|
||||
`setup/scripts/link-user` after adding one.
|
||||
child that did not exist when they were made. Put new shared skills in
|
||||
`user/agents/skills/`, then run `panama update` or `setup/scripts/link-user`.
|
||||
A tool that installs directly into a home skill directory creates a
|
||||
machine-local skill until it is moved into the checkout.
|
||||
|
||||
## It is off unless you say yes
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ticket
|
||||
description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, run pre-mr-review to convergence, and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
|
||||
description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, drive the pre-mr-review audit to convergence (the user runs it themselves from their work Claude account), and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
@@ -372,7 +372,7 @@ place from the start.
|
||||
- Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it)
|
||||
- Resume implementation (some plan steps are already checked off / some commits
|
||||
already exist on the ticket branch — pick up from the first unchecked step)
|
||||
- Run `pre-mr-review` now (implementation looks done, just need the audit + MR doc)
|
||||
- Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc; the user runs the audit from their work account, see Phase 2 step 7)
|
||||
Route to **Phase 1** or **Phase 2** accordingly.
|
||||
- **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user
|
||||
`mr.md` already exists at its path and ask whether they want you to refresh it
|
||||
@@ -620,8 +620,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
Order matters and is not just convenience. `pre-mr-review`'s output is a readiness
|
||||
verdict that gets pasted verbatim into `mr.md`. Anything that runs after it
|
||||
invalidates that verdict by construction, and you end up rerunning it and rewriting
|
||||
the handoff. Review first, fix, commit, and only then run `pre-mr-review` over the
|
||||
final tree.
|
||||
the handoff. Review first, fix, commit, and only then have the user
|
||||
run `pre-mr-review` over the final tree.
|
||||
|
||||
- **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a
|
||||
one line correction with no logic in it. Run it whenever the change adds or
|
||||
@@ -671,11 +671,25 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
in the current repo). If it doesn't, stop here, tell the user implementation and
|
||||
local verification are done but this repo has no `pre-mr-review` skill to run, and
|
||||
let them decide how to proceed.
|
||||
7. Invoke the `pre-mr-review` skill. Read its verdict.
|
||||
7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this
|
||||
review run from the developer's separate work Claude account, so the audit on
|
||||
record must come from there, not from this session. Instead:
|
||||
1. Tell the user the branch is ready for its pre-mr-review and ask them to run
|
||||
`/pre-mr-review` from their work account, then let you know when it has
|
||||
finished. Stop and wait, this is a hard gate.
|
||||
2. When they say it ran, read the audit and handoff files it wrote under
|
||||
`.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review`
|
||||
skill prints the exact paths). Check the `last_reviewed_head` in the audit
|
||||
header matches the current HEAD; if the branch moved after their run, say so
|
||||
and ask them to rerun before acting on a stale audit.
|
||||
3. Read the verdict.
|
||||
- **Ready to Open MR**: continue to step 8.
|
||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its
|
||||
own commit), then invoke `pre-mr-review` again. Repeat until the verdict is Ready
|
||||
to Open MR. Don't write `mr.md` before that verdict is reached.
|
||||
- **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix
|
||||
as its own commit), then ask the user to rerun the review from the work
|
||||
account and wait again. Repeat until the verdict is Ready to Open MR. Don't
|
||||
write `mr.md` before that verdict is reached.
|
||||
Findings that need no code change (mentions, disclosures) get addressed in
|
||||
`mr.md`'s Additional Notes or the Jira fields as usual.
|
||||
8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template,
|
||||
copied into this skill so it still works even though the original
|
||||
`.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md`
|
||||
@@ -707,9 +721,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
||||
the audit's own words, not a retelling of them. Concretely:
|
||||
|
||||
- Never hand-write a handoff section, and never edit one in place. If it is
|
||||
wrong, thin, or stale, fix the handoff file by rerunning `/pre-mr-review`,
|
||||
then re-paste.
|
||||
- On EVERY rerun, replace the whole existing handoff block with the whole
|
||||
wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work
|
||||
account to regenerate the handoff file, then re-paste.
|
||||
- On EVERY rerun of theirs, replace the whole existing handoff block with the whole
|
||||
regenerated one. Do not patch the copy sitting in `mr.md` to match the new
|
||||
head — that is how the two silently diverge, and the version the reviewer
|
||||
reads stops being the version the audit actually produced.
|
||||
|
||||
@@ -16,7 +16,9 @@ ln -s ../../.agents/skills/<name> ~/.claude/skills/<name>
|
||||
Writing a second copy into `~/.claude/skills/` gives you two files that drift, and the drift is
|
||||
silent because each harness only ever reads its own. One home, one symlink per harness that needs it.
|
||||
|
||||
A skill that only makes sense inside one repo belongs in that repo, at `<repo>/.claude/skills/`.
|
||||
A skill that only makes sense inside one repo lives once at
|
||||
`<repo>/.agents/skills/<name>/`. Point Claude Code at that source with a
|
||||
`<repo>/.claude/skills/<name>` symlink.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
|
||||
+6
-7
@@ -28,14 +28,13 @@
|
||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||
link agents/AGENTS.md ~/.codex/AGENTS.md
|
||||
|
||||
# Skills, at both paths that look for them. Linked so that a skill installed by
|
||||
# any tool lands in the checkout and `panama update` offers to commit it.
|
||||
# Skills, at both paths that look for them. Each tracked skill is linked from
|
||||
# this checkout so edits stay shared and `panama update` offers to commit them.
|
||||
#
|
||||
# ~/.agents/skills is this directory and nothing else, so it stays one link.
|
||||
# ~/.claude/skills also holds the skills Panama itself ships, put there by the
|
||||
# link-skills stage, so it is a real directory with one link per skill -- and
|
||||
# this stage runs after that one, so a personal skill wins a name collision.
|
||||
link agents/skills ~/.agents/skills
|
||||
# Both destinations also hold skills Panama itself ships, put there by the
|
||||
# link-skills stage, so both are real directories with one link per skill. This
|
||||
# stage runs after that one, so a personal skill wins a name collision.
|
||||
linkdir agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.claude/skills
|
||||
|
||||
link agents/rules ~/.claude/rules
|
||||
|
||||
Reference in New Issue
Block a user